test(p1r): 验收债批10 account 导出/下载簇真实 PG IT(3 op,4/4 绿)
新增 P1rAccountExportDownloadClusterCompletedApprovalIT,经真实 HTTP 入口 + 真实 PostgreSQL(muse_p1r_account_export_test,独立复跑两次 4/4 绿)覆盖 appCreateExportTask / appGetExportTask / appDownloadExport: - appCreateExportTask:FileService(UnavailableAccountFileServiceFacade,单体 默认)未接入时,按合同把非敏感导出(profile)落库为 queued 任务 + 命令 + 成功审计,绝不伪造 completed/fileRef/下载凭证,不写安全事件;相同 commandId 幂等回放(idempotent_hit 不写第二条);敏感类型(usage)缺 step-up→BAD_REQUEST 回滚;非法 exportType/缺用户/API version 均 fail-closed; - appGetExportTask:按 owner 读任务状态归一、跨 owner not found、纯读 no-write; - appDownloadExport:校验下载凭证(无效→ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID), 凭证可用但 FileService 不可用时正确 fail-closed —— @Transactional 回滚 一次性消费(凭证 consumed_at 仍空可重试)+ 不写下载审计,客户端收 ACCOUNT_FILE_SERVICE_UNAVAILABLE; - 命令/审计走真实实现,仅对专属 _test 库缺库自动创建,保留 _test/密码仅 env/ 凭据 query 守卫。 同步 §五B(剩余 account 9→6)+ muse-module-member/.agent。 证据就绪,completed 待人工批(不自批台账=反假绿)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
91f223acd8
commit
14d7884a1f
@ -125,7 +125,8 @@
|
||||
- **批7 ✅ 已交付(account 配额请求 3 op,2026-06-16)**:`P1rAccountQuotaRequestCompletedApprovalIT`(真实 PG `muse_p1r_account_quota_request_test`,5/5 绿,独立复跑两次均绿)覆盖 appCreateQuotaRequest/appGetQuotaRequest/adminCreateQuotaRequest;经真实 `/app-api/muse/account/quota-requests` 与 `/admin-api/muse/account/users/{userId}/quota-requests`(member 模块租户行拦截器 + member_user fixture + admin `@PreAuthorize` method security),断言:create 入队写 quota_request(queued)+ pending_attribution 外部调用占位 + completed command(app:actor=owner=登录用户、amount=0;admin:actor=操作者、owner=目标用户、amount=targetQuota.limit)、命令幂等回放(idempotent_hit 不写第二条)、相同 commandId 不同请求 hash→`ACCOUNT_COMMAND_ID_CONFLICT` 事务回滚;get 按 owner+tenant 隔离返回归一化状态、纯读 no-write;fail-closed(类型白名单 BAD_REQUEST/API version/缺用户 `ACCOUNT_USER_NOT_EXISTS`/越权 owner/not-found/RBAC deny)全程 0 写、0 command 预占;配额请求链路不写 Account 审计(audit/outbox 桩抛错反假绿)。10 条负路全部经真实 GlobalExceptionHandler 实测命中(9 serviceException + 1 accessDenied)。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。
|
||||
- **批8 ✅ 已交付(account 用量与外部调用可观测读端 3 op,2026-06-16)**:`P1rAccountUsageObservabilityCompletedApprovalIT`(真实 PG `muse_p1r_account_usage_observability_test`,4/4 绿,独立复跑两次均绿)覆盖 getAppUsage/appGetIntegrationCallByCorrelation/adminGetIntegrationCallByCorrelation;经真实 `/app-api/muse/account/usage`、`/app-api/muse/account/integration-calls/by-correlation/{id}` 与 `/admin-api/muse/account/integration-calls/by-correlation/{id}`(member 模块租户行拦截器 + member_user fixture + admin `@PreAuthorize` method security),断言:getAppUsage 按 owner 聚合(token 输入 160/输出 235 求和、pending 计数 1、anomaly 计数 1、byModel MiniMaxM2=380/GLM=15、byAttribution work+agent)且跨用户隔离(他人 9999 不计入)、period 规范化(month)与白名单(yearly→BAD_REQUEST);app 侧外部调用读 owner 守卫(自有→归一化 pending_attribution/quota_request/retryGroup、越权→`ACCOUNT_RESOURCE_FORBIDDEN`),admin 侧无 owner 限制(可读任意用户、status/requestType 归一化、RBAC=`muse:account:query`);三 op 纯读全程 no-write(member_user/usage_record/integration_call/command/audit 快照前后一致),fail-closed(缺用户 `ACCOUNT_USER_NOT_EXISTS`/not-found `ACCOUNT_INTEGRATION_CALL_NOT_EXISTS`/API version/RBAC deny FORBIDDEN)。控制器附带依赖(权益/绑定/配额/归因 service)用抛错 mock 反假绿越界。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。
|
||||
- **批9 ✅ 已交付(account New-API 绑定簇 3 op,2026-06-16)**:`P1rAccountNewApiBindingClusterCompletedApprovalIT`(真实 PG `muse_p1r_account_newapi_binding_test`,4/4 绿,独立复跑两次均绿)覆盖 getAppNewApiBinding/adminListNewApiBindings/appRecheckNewApiBinding;经真实 `/app-api/muse/account/new-api-binding`、`/admin-api/muse/account/new-api-bindings`、`/app-api/muse/account/new-api-binding/recheck`(member 模块租户行拦截器 + member_user fixture + admin `@PreAuthorize` method security),断言:getAppNewApiBinding 按 owner 读绑定摘要 + 存储态归一(active→bound、无绑定→unbound 且不可重验)、纯读 no-write;adminListNewApiBindings 以账户用户为分页主表(无筛选按 id 倒序返 bound/unbound/sync_failed 三态 + syncErrorMessage)、bindingStatus 走 EXISTS/NOT EXISTS 筛选、非法状态 BAD_REQUEST、RBAC=`muse:account:query` fail-closed、纯读 no-write;appRecheckNewApiBinding 在单体 New-API facade(`UnavailableNewApiAccountFacade`)不可用时**正确 fail-closed**——业务 `@Transactional` 回滚命令预占(0 残留),失败外部调用经 `REQUIRES_NEW` 独立落库一条 `unavailable`(error_code 可排障),binding 不被改写,客户端收 `ACCOUNT_NEW_API_UNAVAILABLE`;binding 不存在/缺用户/API version 三守卫全程 0 写。控制器/服务附带依赖(quota/usage/audit service)用抛错 mock 反假绿越界。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。
|
||||
- 剩余:content 26(约 15 为 FileService/New-API/SSE fail-closed → 只能验"失败关闭"真实证据)+ market 14 + account 9,按批续推。
|
||||
- **批10 ✅ 已交付(account 导出/下载簇 3 op,2026-06-16)**:`P1rAccountExportDownloadClusterCompletedApprovalIT`(真实 PG `muse_p1r_account_export_test`,4/4 绿,独立复跑两次均绿)覆盖 appCreateExportTask/appGetExportTask/appDownloadExport;经真实 `/app-api/muse/account/export-tasks`、`/app-api/muse/account/export-tasks/{taskId}`、`/app-api/muse/account/downloads/{credentialId}`(member 模块租户行拦截器 + member_user fixture),断言:create 在 FileService(`UnavailableAccountFileServiceFacade`,单体默认)未接入时按合同把非敏感导出(profile)落库为 queued 任务 + 命令 + 成功审计、绝不伪造 completed/fileRef/下载凭证、不写安全事件,相同 commandId 幂等回放(idempotent_hit 不写第二条),敏感类型(usage)缺 step-up→BAD_REQUEST 回滚、非法 exportType/缺用户/API version 均 fail-closed;get 按 owner 读任务状态归一、跨 owner not found、纯读 no-write;download 校验下载凭证(无效→`ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID`),凭证可用但 FileService 不可用时**正确 fail-closed**——`@Transactional` 回滚一次性消费(凭证 consumed_at 仍空可重试)+ 不写下载审计,客户端收 `ACCOUNT_FILE_SERVICE_UNAVAILABLE`。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。
|
||||
- 剩余:content 26(约 15 为 FileService/New-API/SSE fail-closed → 只能验"失败关闭"真实证据)+ market 14 + account 6,按批续推。
|
||||
- ✅ **P1r*IT 真实 PG 基线(2026-06-15,mini-infra PG)= 23/23 IT 类全绿**(99 用例 0F/0E;2 例 external-acceptance 因未设 `MUSE_P1R_EXTERNAL_ACCEPTANCE` 跳过)。`P1rKnowledgeFlywayMigrationIT` 版本断言由硬编码(V14→V21→V23 复发两次)改为 `MigrateResult` 动态自适应(commit 4d46d7a,Codex+Opus 双代理合并)。**完整跑法见 [.agents/knowledge §四](../../.agents/knowledge/external-deps-and-gotchas.md)**:`_test` 后缀隔离库 + `source infra.env`(密码仅 env)+ argLine(SOCKS 清 + p1r.flyway.url/user/locations)+ `-DreuseForks=false`(批量必加,避 Market IT 属性脱敏污染复用 fork)。
|
||||
- 🔧 ai/平台预存红测试整改(CI 接电将暴露):`MuseAiTaskServiceTest` 桩缺失 11 例 NPE **✅ 已整改(补 eventPublishOutboxService/candidateReviewService 桩 + review lenient,40/40,2026-06-15)**;`QiniuSmsClientTest` 时区硬编码 **✅ 已整改(5/5,机器无关)**;`MuseAiEventPublishOutboxMapperTest` **✅ 真实 PG 实跑 5/5(mini-infra PG,harness=infra.env+argLine SOCKS 清;隔离 schema 自建自清)**。**ai/平台预存红三项全清。**
|
||||
|
||||
|
||||
@ -5,5 +5,5 @@
|
||||
- **目标(owner 职责)**:Account/Usage/Audit BC——Profile / Entitlement-Quota / Usage Record / Personal Center Summary / 记录总览 / Security Event / New-API Binding / Audit Log / Download Credential。**member = Account BC 物理承载,不新增 account 模块**。
|
||||
- **边界(不可违反)**:是用户可见权益的聚合**读模型**与跳转入口,**不是消费账本事实源,不能反写**作品/知识/智能体/市场事实;高危审计 append-only + WORM/哈希链;Entitlement/Quota/Usage/Security/Binding 唯一写入 authority;守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。
|
||||
- **out-of-scope**:他域事实写入。
|
||||
- **现状**:只读评估 68%。**2026-06-15 活体订正**:单体内 `MarketAccountProjectionFacade` 由 market 的 `MarketAccountProjectionProvider` 真实提供,purchases/licenses/publish-records 三端可读投影,不是 `*_UNAVAILABLE` 缺口。**2026-06-16 补真实 PG 验收证据**:`P1rAccountMarketRecordsCompletedApprovalIT` 覆盖 appListPurchases/appListLicenses/appListPublishRecords,真实 PG 3/3 绿;`P1rAccountQuotaRequestCompletedApprovalIT` 覆盖 appCreateQuotaRequest/appGetQuotaRequest/adminCreateQuotaRequest(命令幂等+commandId 冲突回滚+owner/tenant 隔离+RBAC+类型白名单),真实 PG 5/5 绿(独立复跑);`P1rAccountUsageObservabilityCompletedApprovalIT` 覆盖 getAppUsage/appGetIntegrationCallByCorrelation/adminGetIntegrationCallByCorrelation(用量按 owner 聚合 token/pending/anomaly/byModel+跨用户隔离+period 白名单、外部调用 app owner 守卫/admin 无 owner 限制+RBAC、三 op 纯读 no-write、缺用户/not-found/API version/RBAC fail-closed),真实 PG 4/4 绿(独立复跑);`P1rAccountNewApiBindingClusterCompletedApprovalIT` 覆盖 getAppNewApiBinding/adminListNewApiBindings/appRecheckNewApiBinding(绑定摘要 owner 读 + active→bound 归一、admin 列表以账户用户为分页主表 + EXISTS/NOT EXISTS 状态筛选 + RBAC、两读 no-write、recheck 单体 New-API 不可用正确 fail-closed:命令预占回滚但失败调用 REQUIRES_NEW 独立落库),真实 PG 4/4 绿(独立复跑);证据就绪,completed 仍待人工批准。
|
||||
- **关键风险 / TODO**:剩余 account 验收债 9 op(quota-request 3 op 批7 + usage/integration-call 3 op 批8 + new-api 绑定簇 3 op 批9 已补证,recheck 已证正确 fail-closed):exports/downloads(appCreateExportTask/appGetExportTask/appDownloadExport)属对象存储 FileService 未配置的正确 fail-closed、adminCreateNewApiBinding/call-attribution-job 属 New-API 外部依赖;adminListUsageRecords/adminListPurchaseRecords/adminGetCallAttributionJob/appAcknowledgeSecurityEvent 等内部读写需按批补真实 PG 证据。前端仍有深页未全覆盖真实后端。
|
||||
- **现状**:只读评估 68%。**2026-06-15 活体订正**:单体内 `MarketAccountProjectionFacade` 由 market 的 `MarketAccountProjectionProvider` 真实提供,purchases/licenses/publish-records 三端可读投影,不是 `*_UNAVAILABLE` 缺口。**2026-06-16 补真实 PG 验收证据**:`P1rAccountMarketRecordsCompletedApprovalIT` 覆盖 appListPurchases/appListLicenses/appListPublishRecords,真实 PG 3/3 绿;`P1rAccountQuotaRequestCompletedApprovalIT` 覆盖 appCreateQuotaRequest/appGetQuotaRequest/adminCreateQuotaRequest(命令幂等+commandId 冲突回滚+owner/tenant 隔离+RBAC+类型白名单),真实 PG 5/5 绿(独立复跑);`P1rAccountUsageObservabilityCompletedApprovalIT` 覆盖 getAppUsage/appGetIntegrationCallByCorrelation/adminGetIntegrationCallByCorrelation(用量按 owner 聚合 token/pending/anomaly/byModel+跨用户隔离+period 白名单、外部调用 app owner 守卫/admin 无 owner 限制+RBAC、三 op 纯读 no-write、缺用户/not-found/API version/RBAC fail-closed),真实 PG 4/4 绿(独立复跑);`P1rAccountNewApiBindingClusterCompletedApprovalIT` 覆盖 getAppNewApiBinding/adminListNewApiBindings/appRecheckNewApiBinding(绑定摘要 owner 读 + active→bound 归一、admin 列表以账户用户为分页主表 + EXISTS/NOT EXISTS 状态筛选 + RBAC、两读 no-write、recheck 单体 New-API 不可用正确 fail-closed:命令预占回滚但失败调用 REQUIRES_NEW 独立落库),真实 PG 4/4 绿(独立复跑);`P1rAccountExportDownloadClusterCompletedApprovalIT` 覆盖 appCreateExportTask/appGetExportTask/appDownloadExport(FileService 未接入时非敏感导出落 queued 任务+命令+审计、不伪造 completed/凭证、敏感类型缺 step-up fail-closed、命令幂等;get owner 读 + 跨 owner not found + no-write;download 凭证门禁 + FileService 不可用正确 fail-closed:@Transactional 回滚一次性消费、不写审计),真实 PG 4/4 绿(独立复跑);证据就绪,completed 仍待人工批准。
|
||||
- **关键风险 / TODO**:剩余 account 验收债 6 op(quota-request 3 op 批7 + usage/integration-call 3 op 批8 + new-api 绑定簇 3 op 批9 + 导出/下载簇 3 op 批10 已补证):adminCreateNewApiBinding/adminCreateCallAttributionJob 属 New-API/SSE 外部依赖(写路 fail-closed);adminListUsageRecords/adminListPurchaseRecords/adminGetCallAttributionJob(admin 内部读)+ appAcknowledgeSecurityEvent(内部写)等需按批补真实 PG 证据。前端仍有深页未全覆盖真实后端。
|
||||
|
||||
@ -0,0 +1,906 @@
|
||||
package cn.iocoder.muse.server.framework.api;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.muse.framework.common.biz.infra.logger.ApiErrorLogCommonApi;
|
||||
import cn.iocoder.muse.framework.common.biz.infra.logger.dto.ApiErrorLogCreateReqDTO;
|
||||
import cn.iocoder.muse.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.muse.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.muse.framework.datasource.config.MuseDataSourceAutoConfiguration;
|
||||
import cn.iocoder.muse.framework.mybatis.config.MuseMybatisAutoConfiguration;
|
||||
import cn.iocoder.muse.framework.mybatis.core.util.MyBatisUtils;
|
||||
import cn.iocoder.muse.framework.security.core.LoginUser;
|
||||
import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.muse.framework.tenant.config.TenantProperties;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.framework.tenant.core.db.TenantDatabaseInterceptor;
|
||||
import cn.iocoder.muse.framework.web.config.MuseWebAutoConfiguration;
|
||||
import cn.iocoder.muse.module.member.application.account.AccountAuditServiceImpl;
|
||||
import cn.iocoder.muse.module.member.application.account.AccountCommandServiceImpl;
|
||||
import cn.iocoder.muse.module.member.application.account.AccountExportServiceImpl;
|
||||
import cn.iocoder.muse.module.member.application.account.facade.AccountFileServiceFacade;
|
||||
import cn.iocoder.muse.module.member.application.account.facade.UnavailableAccountFileServiceFacade;
|
||||
import cn.iocoder.muse.module.member.controller.app.account.AppAccountExportController;
|
||||
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import com.github.yulichang.autoconfigure.MybatisPlusJoinAutoConfiguration;
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.flywaydb.core.api.output.MigrateResult;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestInstance;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.SpringBootConfiguration;
|
||||
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
|
||||
import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
|
||||
import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.NOT_FOUND;
|
||||
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_API_VERSION_UNSUPPORTED;
|
||||
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID;
|
||||
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_FILE_SERVICE_UNAVAILABLE;
|
||||
import static cn.iocoder.muse.module.member.enums.ErrorCodeConstants.ACCOUNT_USER_NOT_EXISTS;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* P1R Account 导出/下载簇 completed approval:3 个 operation 的 HTTP + 真实 PostgreSQL 证据。
|
||||
*
|
||||
* <p>本测试只连接显式传入的 PostgreSQL {@code _test} 隔离库,经真实 {@code /app-api/muse/account/export-tasks}、
|
||||
* {@code /app-api/muse/account/export-tasks/{taskId}} 与 {@code /app-api/muse/account/downloads/{credentialId}}
|
||||
* 入口进入 Controller,覆盖 appCreateExportTask、appGetExportTask、appDownloadExport。断言:create 在
|
||||
* FileService 未接入时按合同把非敏感导出落库为 queued 任务(+ 命令幂等 + 审计),敏感类型(usage/security_events)
|
||||
* 因缺 step-up 在服务层 fail-closed,exportType 白名单/缺用户/API version 均 fail-closed 回滚;get 按 owner 读
|
||||
* 任务状态、跨 owner 不可见、纯读 no-write;download 校验下载凭证(无效凭证拒绝),凭证可用但 FileService 不可用时
|
||||
* **正确 fail-closed**——业务 `@Transactional` 回滚一次性消费 + 不写审计,客户端收 {@code ACCOUNT_FILE_SERVICE_UNAVAILABLE}。
|
||||
* 全部接真实 {@link UnavailableAccountFileServiceFacade}(单体默认,createExportTask/downloadExportPackage 均
|
||||
* unavailable),命令/审计走真实实现,不伪造 completed/凭证/文件字节。</p>
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = P1rAccountExportDownloadClusterCompletedApprovalIT.ExportDownloadClusterCompletedApprovalConfiguration.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.MOCK
|
||||
)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class P1rAccountExportDownloadClusterCompletedApprovalIT {
|
||||
|
||||
private static final Long TENANT_ID = 100L;
|
||||
private static final Long OWNER_USER_ID = 1001L;
|
||||
private static final Long OTHER_USER_ID = 2002L;
|
||||
private static final Long MISSING_USER_ID = 9099L;
|
||||
private static final String API_VERSION = "1";
|
||||
private static final String SEED_REQUEST_HASH = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
private static final Set<String> CREDENTIAL_QUERY_KEYS = Set.of(
|
||||
"user", "username", "password", "pass", "pwd", "sslpassword", "ssl_password",
|
||||
"token", "secret", "api_key", "apikey", "bearer", "access_token", "refresh_token");
|
||||
|
||||
private static volatile CompletedApprovalSettings cachedSettings;
|
||||
private static volatile boolean originalFlywayPropertiesCaptured;
|
||||
private static volatile String originalFlywayUrlSystemProperty;
|
||||
private static volatile String originalFlywayUserSystemProperty;
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
@Autowired
|
||||
private WebApplicationContext webApplicationContext;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) {
|
||||
CompletedApprovalSettings settings = settings();
|
||||
redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser());
|
||||
registry.add("spring.application.name", () -> "p1r-account-export-download-cluster-completed-approval-it");
|
||||
registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module.member");
|
||||
registry.add("muse.web.admin-ui.url", () -> "http://localhost");
|
||||
registry.add("spring.datasource.url", settings::jdbcUrl);
|
||||
registry.add("spring.datasource.username", settings::jdbcUser);
|
||||
registry.add("spring.datasource.password", settings::jdbcPassword);
|
||||
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
|
||||
registry.add("spring.main.banner-mode", () -> "off");
|
||||
registry.add("spring.main.lazy-initialization", () -> "true");
|
||||
registry.add("mybatis-plus.global-config.db-config.id-type", () -> "AUTO");
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
void migrateAccountSchemaAndCreateMemberUserFixture() {
|
||||
CompletedApprovalSettings settings = settings();
|
||||
silenceFlywayInfoLogs();
|
||||
ensureTestDatabaseExists(settings);
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(settings.jdbcUrl(), settings.jdbcUser(), settings.jdbcPassword())
|
||||
.locations(resolveMuseSqlLocation(settings.flywayLocations()))
|
||||
.schemas("public")
|
||||
.defaultSchema("public")
|
||||
.cleanDisabled(false)
|
||||
.load();
|
||||
cleanSchema(flyway, settings);
|
||||
MigrateResult result = migrateSchema(flyway, settings);
|
||||
// 导出任务/下载凭证/安全事件(V2)+ 命令/审计(V11)在 V11 内就绪。
|
||||
assertTrue(result.migrationsExecuted >= 11,
|
||||
"Account 导出/下载簇 completed approval 至少要迁移到含 V11 命令/审计 schema,实际: "
|
||||
+ result.migrationsExecuted);
|
||||
createMemberUserFixtureTable();
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
|
||||
resetAccountTables();
|
||||
setRuntimeContext(OWNER_USER_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void restoreFlywaySystemProperties() {
|
||||
restoreOriginalFlywaySystemProperties();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectUnsafeDatabaseConfigurationInputs() {
|
||||
System.setProperty("p1r.account.export.password", "must-not-be-used");
|
||||
try {
|
||||
AssertionError error = assertThrows(AssertionError.class,
|
||||
P1rAccountExportDownloadClusterCompletedApprovalIT::assertNoPasswordSystemProperties);
|
||||
assertTrue(error.getMessage().contains("p1r.account.export.password"),
|
||||
"拒绝 JVM password system property 时必须指出属性名");
|
||||
} finally {
|
||||
System.clearProperty("p1r.account.export.password");
|
||||
}
|
||||
|
||||
AssertionError credentialQueryError = assertThrows(AssertionError.class,
|
||||
() -> assertNoCredentialQuery("jdbc:postgresql://localhost:5432/muse_test?password=secret"));
|
||||
assertTrue(credentialQueryError.getMessage().contains("p1r.flyway.url 不能携带凭据 query 参数"),
|
||||
"拒绝 JDBC credential query 时必须说明连接串只能通过环境变量传密码");
|
||||
|
||||
AssertionError databaseNameError = assertThrows(AssertionError.class,
|
||||
() -> assertTestDatabaseUrl("jdbc:postgresql://localhost:5432/muse_prod"));
|
||||
assertTrue(databaseNameError.getMessage().contains("_test"),
|
||||
"拒绝非 _test 数据库时必须指出隔离库后缀要求");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_createExportTaskQueuedWhenFileServiceUnavailableWithIdempotencyAndStepUpGuards() throws Exception {
|
||||
seedMemberUser(OWNER_USER_ID, "Export Owner");
|
||||
|
||||
// 非敏感导出:FileService 未接入 → 合同允许的 queued 任务落库 + 命令 + 审计,绝不伪造 completed/凭证。
|
||||
mockMvc.perform(post("/app-api/muse/account/export-tasks")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.contentType("application/json")
|
||||
.content("{\"commandId\":\"cmd-export-1\",\"exportType\":\"profile\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.taskId").exists())
|
||||
.andExpect(jsonPath("$.data.status").value("queued"));
|
||||
assertEquals(1, queryInt("SELECT count(*) FROM muse_account_export_task "
|
||||
+ "WHERE account_user_id = ? AND export_type = 'profile' AND status = 'queued' "
|
||||
+ "AND file_ref IS NULL", OWNER_USER_ID),
|
||||
"create 必须落库一条 queued 导出任务(无伪造 fileRef)");
|
||||
assertEquals(1, queryInt("SELECT count(*) FROM muse_account_command "
|
||||
+ "WHERE owner_user_id = ? AND operation_id = 'appCreateExportTask'", OWNER_USER_ID),
|
||||
"create 必须落库一条命令");
|
||||
assertEquals(1, queryInt("SELECT count(*) FROM muse_account_audit "
|
||||
+ "WHERE account_user_id = ? AND operation_id = 'appCreateExportTask' AND status = 'succeeded'",
|
||||
OWNER_USER_ID),
|
||||
"create 必须落库一条成功审计");
|
||||
assertEquals(0, queryInt("SELECT count(*) FROM muse_account_download_credential", new Object[]{}),
|
||||
"FileService 不可用时不能伪造下载凭证");
|
||||
assertEquals(0, queryInt("SELECT count(*) FROM muse_member_security_event", new Object[]{}),
|
||||
"非敏感导出不应写安全事件");
|
||||
|
||||
// 相同 commandId + 相同请求 → 幂等回放,不写第二条任务。
|
||||
mockMvc.perform(post("/app-api/muse/account/export-tasks")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.contentType("application/json")
|
||||
.content("{\"commandId\":\"cmd-export-1\",\"exportType\":\"profile\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("idempotent_hit"));
|
||||
assertEquals(1, queryInt("SELECT count(*) FROM muse_account_export_task WHERE account_user_id = ?", OWNER_USER_ID),
|
||||
"幂等回放不能写第二条导出任务");
|
||||
|
||||
// 敏感导出(usage)缺 step-up → 服务层 fail-closed,即便带 dateRange 也回滚。
|
||||
assertNoAccountMutationDuring("敏感导出缺 step-up 必须 fail-closed",
|
||||
() -> mockMvc.perform(post("/app-api/muse/account/export-tasks")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.contentType("application/json")
|
||||
.content("{\"commandId\":\"cmd-export-2\",\"exportType\":\"usage\","
|
||||
+ "\"dateRange\":{\"startDate\":\"2026-06-01\",\"endDate\":\"2026-06-16\"}}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// 非法 exportType → BAD_REQUEST,不写库。
|
||||
assertNoAccountMutationDuring("非法 exportType 必须 fail-closed",
|
||||
() -> mockMvc.perform(post("/app-api/muse/account/export-tasks")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.contentType("application/json")
|
||||
.content("{\"commandId\":\"cmd-export-3\",\"exportType\":\"not-a-type\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(BAD_REQUEST.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// 缺失登录用户 → fail-closed,不写库。
|
||||
setRuntimeContext(MISSING_USER_ID);
|
||||
assertNoAccountMutationDuring("缺失登录用户必须 fail-closed",
|
||||
() -> mockMvc.perform(post("/app-api/muse/account/export-tasks")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.contentType("application/json")
|
||||
.content("{\"commandId\":\"cmd-export-4\",\"exportType\":\"profile\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ACCOUNT_USER_NOT_EXISTS.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// API version 非法 → 守卫拒绝,不写库。
|
||||
setRuntimeContext(OWNER_USER_ID);
|
||||
assertNoAccountMutationDuring("非法 API version 必须 fail-closed",
|
||||
() -> mockMvc.perform(post("/app-api/muse/account/export-tasks")
|
||||
.header("X-API-Version", "0")
|
||||
.contentType("application/json")
|
||||
.content("{\"commandId\":\"cmd-export-5\",\"exportType\":\"profile\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_getExportTaskForOwnerWithStatusAndNoWrite() throws Exception {
|
||||
seedMemberUser(OWNER_USER_ID, "Export Owner");
|
||||
seedMemberUser(OTHER_USER_ID, "Other Owner");
|
||||
seedExportTask(OWNER_USER_ID, "acct-exp-own", "cmd-seed-own", "profile", "queued", null, false);
|
||||
seedExportTask(OTHER_USER_ID, "acct-exp-foreign", "cmd-seed-foreign", "purchases", "queued", null, false);
|
||||
|
||||
// 属主读自己的任务 → 归一化状态,纯读。
|
||||
assertNoAccountMutationDuring("getExportTask happy path 是纯读 operation",
|
||||
() -> mockMvc.perform(get("/app-api/muse/account/export-tasks/{taskId}", "acct-exp-own")
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.taskId").value("acct-exp-own"))
|
||||
.andExpect(jsonPath("$.data.exportType").value("profile"))
|
||||
.andExpect(jsonPath("$.data.status").value("queued")));
|
||||
|
||||
// 跨 owner 不可见 → not found,不泄露,不写库。
|
||||
assertNoAccountMutationDuring("getExportTask 跨 owner 必须 not found",
|
||||
() -> mockMvc.perform(get("/app-api/muse/account/export-tasks/{taskId}", "acct-exp-foreign")
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(NOT_FOUND.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// 不存在的任务 → not found,不写库。
|
||||
assertNoAccountMutationDuring("getExportTask 不存在必须 not found",
|
||||
() -> mockMvc.perform(get("/app-api/muse/account/export-tasks/{taskId}", "acct-exp-missing")
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(NOT_FOUND.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// 缺失登录用户 → fail-closed,不写库。
|
||||
setRuntimeContext(MISSING_USER_ID);
|
||||
assertNoAccountMutationDuring("getExportTask 缺失登录用户必须拒绝",
|
||||
() -> mockMvc.perform(get("/app-api/muse/account/export-tasks/{taskId}", "acct-exp-own")
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ACCOUNT_USER_NOT_EXISTS.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// API version 非法 → 拒绝,不写库。
|
||||
setRuntimeContext(OWNER_USER_ID);
|
||||
assertNoAccountMutationDuring("getExportTask 非法 API version 必须拒绝",
|
||||
() -> mockMvc.perform(get("/app-api/muse/account/export-tasks/{taskId}", "acct-exp-own")
|
||||
.header("X-API-Version", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_downloadExportFailClosedWhenFileServiceUnavailableAndRejectInvalidCredential() throws Exception {
|
||||
seedMemberUser(OWNER_USER_ID, "Export Owner");
|
||||
// 已完成任务 + 可用下载凭证:凭证门禁全过,但 FileService 不可用 → 失败关闭。
|
||||
seedExportTask(OWNER_USER_ID, "acct-exp-done", "cmd-seed-done", "profile", "completed", "file-ref-1", false);
|
||||
String credentialId = "test-cred-1001";
|
||||
seedDownloadCredential(OWNER_USER_ID, "acct-exp-done", sha256Hex(credentialId));
|
||||
|
||||
// 凭证可用 + FileService 不可用 → ACCOUNT_FILE_SERVICE_UNAVAILABLE;一次性消费与审计随事务回滚,净零写。
|
||||
assertNoAccountMutationDuring("download 在 FileService 不可用时必须失败关闭且回滚消费",
|
||||
() -> mockMvc.perform(get("/app-api/muse/account/downloads/{credentialId}", credentialId)
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ACCOUNT_FILE_SERVICE_UNAVAILABLE.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
// 显式复核:回滚后凭证仍未被消费(可重试),且未写下载审计。
|
||||
assertEquals(0, queryInt("SELECT count(*) FROM muse_account_download_credential "
|
||||
+ "WHERE account_user_id = ? AND consumed_at IS NOT NULL", OWNER_USER_ID),
|
||||
"FileService 失败关闭后下载凭证不能被一次性消费(事务回滚)");
|
||||
assertEquals(0, queryInt("SELECT count(*) FROM muse_account_audit WHERE operation_id = 'appDownloadExport'",
|
||||
new Object[]{}),
|
||||
"失败关闭的下载不能写成功审计");
|
||||
|
||||
// 无效/不存在凭证 → 拒绝,不写库,不泄露。
|
||||
assertNoAccountMutationDuring("download 无效凭证必须拒绝",
|
||||
() -> mockMvc.perform(get("/app-api/muse/account/downloads/{credentialId}", "nonexistent-cred")
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ACCOUNT_DOWNLOAD_CREDENTIAL_INVALID.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// API version 非法 → 守卫拒绝,不写库。
|
||||
assertNoAccountMutationDuring("download 非法 API version 必须拒绝",
|
||||
() -> mockMvc.perform(get("/app-api/muse/account/downloads/{credentialId}", credentialId)
|
||||
.header("X-API-Version", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ACCOUNT_API_VERSION_UNSUPPORTED.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
}
|
||||
|
||||
// ==================== 运行期上下文 ====================
|
||||
|
||||
private void setRuntimeContext(Long userId) {
|
||||
TenantContextHolder.setTenantId(TENANT_ID);
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setId(userId);
|
||||
loginUser.setUserType(UserTypeEnum.MEMBER.getValue());
|
||||
loginUser.setTenantId(TENANT_ID);
|
||||
loginUser.setVisitTenantId(TENANT_ID);
|
||||
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
|
||||
}
|
||||
|
||||
// ==================== 种子 / 重置 ====================
|
||||
|
||||
private void resetAccountTables() throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
// 每个用例重置导出/下载/安全事件 + 命令/审计表,保证 no-write 与 fail-closed 断言可识别任何误写。
|
||||
statement.execute("""
|
||||
TRUNCATE TABLE
|
||||
muse_account_audit,
|
||||
muse_account_command,
|
||||
muse_account_download_credential,
|
||||
muse_account_export_task,
|
||||
muse_member_security_event,
|
||||
member_user
|
||||
RESTART IDENTITY CASCADE
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
private void seedMemberUser(Long userId, String nickname) throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO member_user(id, mobile, password, status, register_ip, register_terminal,
|
||||
login_ip, login_date, nickname, avatar, name, sex, birthday, area_id,
|
||||
mark, point, tag_ids, level_id, experience, group_id, tenant_id)
|
||||
VALUES (?, ?, 'encoded-password', 0, '127.0.0.1', 1,
|
||||
'127.0.0.1', CURRENT_TIMESTAMP, ?, '', ?, 0, CURRENT_TIMESTAMP, 0,
|
||||
'', 0, NULL, NULL, 0, NULL, ?)
|
||||
""")) {
|
||||
statement.setLong(1, userId);
|
||||
statement.setString(2, "1380000" + userId);
|
||||
statement.setString(3, nickname);
|
||||
statement.setString(4, nickname);
|
||||
statement.setLong(5, TENANT_ID);
|
||||
assertEquals(1, statement.executeUpdate(), "seed member_user fixture 必须插入一行");
|
||||
}
|
||||
}
|
||||
|
||||
private void seedExportTask(Long accountUserId, String taskId, String commandId, String exportType,
|
||||
String taskStatus, String fileRef, boolean sourceBlocked) throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO muse_account_export_task(task_id, account_user_id, command_id, request_hash, export_type,
|
||||
status, file_ref, source_blocked, completed_at, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""")) {
|
||||
statement.setString(1, taskId);
|
||||
statement.setLong(2, accountUserId);
|
||||
statement.setString(3, commandId);
|
||||
statement.setString(4, SEED_REQUEST_HASH);
|
||||
statement.setString(5, exportType);
|
||||
statement.setString(6, taskStatus);
|
||||
statement.setString(7, fileRef);
|
||||
statement.setBoolean(8, sourceBlocked);
|
||||
if ("completed".equals(taskStatus)) {
|
||||
statement.setObject(9, java.time.LocalDateTime.now());
|
||||
} else {
|
||||
statement.setNull(9, java.sql.Types.TIMESTAMP);
|
||||
}
|
||||
statement.setLong(10, TENANT_ID);
|
||||
assertEquals(1, statement.executeUpdate(), "seed 导出任务必须插入一行");
|
||||
}
|
||||
}
|
||||
|
||||
private void seedDownloadCredential(Long accountUserId, String taskId, String credentialHash) throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO muse_account_download_credential(credential_hash, account_user_id, task_id, expires_at,
|
||||
consumed_at, revoked_at, source_blocked, tenant_id)
|
||||
VALUES (?, ?, ?, ?, NULL, NULL, FALSE, ?)
|
||||
""")) {
|
||||
statement.setString(1, credentialHash);
|
||||
statement.setLong(2, accountUserId);
|
||||
statement.setString(3, taskId);
|
||||
// 凭证过期时间设为未来,确保门禁可用,失败关闭只能由 FileService 触发。
|
||||
statement.setObject(4, java.time.LocalDateTime.now().plusHours(1));
|
||||
statement.setLong(5, TENANT_ID);
|
||||
assertEquals(1, statement.executeUpdate(), "seed 下载凭证必须插入一行");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 无突变断言(反假绿) ====================
|
||||
|
||||
private void assertNoAccountMutationDuring(String message, CheckedOperation operation) throws Exception {
|
||||
AccountFactSnapshot before = accountFactSnapshot();
|
||||
operation.run();
|
||||
assertEquals(before, accountFactSnapshot(), message + ":不能新增、删除或更新任何 Account fact");
|
||||
}
|
||||
|
||||
private AccountFactSnapshot accountFactSnapshot() throws SQLException {
|
||||
return new AccountFactSnapshot(
|
||||
tableSnapshot("member_user", "tenant_id, id"),
|
||||
tableSnapshot("muse_account_export_task", "tenant_id, id"),
|
||||
tableSnapshot("muse_account_download_credential", "tenant_id, id"),
|
||||
tableSnapshot("muse_member_security_event", "tenant_id, id"),
|
||||
tableSnapshot("muse_account_command", "tenant_id, id"),
|
||||
tableSnapshot("muse_account_audit", "tenant_id, id")
|
||||
);
|
||||
}
|
||||
|
||||
private List<String> tableSnapshot(String tableName, String orderBy) throws SQLException {
|
||||
String sql = """
|
||||
SELECT row_to_json(t)::text
|
||||
FROM %s t
|
||||
ORDER BY %s
|
||||
""".formatted(tableName, orderBy);
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = statement.executeQuery()) {
|
||||
List<String> rows = new ArrayList<>();
|
||||
while (resultSet.next()) {
|
||||
rows.add(resultSet.getString(1));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
private int queryInt(String sql, Object... args) throws SQLException {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement(sql)) {
|
||||
for (int i = 0; i < args.length; i++) {
|
||||
Object value = args[i];
|
||||
if (value instanceof Long longValue) {
|
||||
statement.setLong(i + 1, longValue);
|
||||
} else {
|
||||
statement.setString(i + 1, String.valueOf(value));
|
||||
}
|
||||
}
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertTrue(resultSet.next(), "计数查询必须返回一行");
|
||||
return resultSet.getInt(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String sha256Hex(String value) {
|
||||
try {
|
||||
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== member_user fixture ====================
|
||||
|
||||
private void createMemberUserFixtureTable() {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
// member_user 是 yudao 基座表,不在 muse Flyway 迁移内;隔离库需自建 fixture 供 requireUser 校验。
|
||||
statement.execute("""
|
||||
CREATE TABLE IF NOT EXISTS member_user (
|
||||
id BIGINT PRIMARY KEY,
|
||||
mobile VARCHAR(32),
|
||||
password VARCHAR(255),
|
||||
status INTEGER,
|
||||
register_ip VARCHAR(64),
|
||||
register_terminal INTEGER,
|
||||
login_ip VARCHAR(64),
|
||||
login_date TIMESTAMP,
|
||||
nickname VARCHAR(64),
|
||||
avatar VARCHAR(512),
|
||||
name VARCHAR(64),
|
||||
sex INTEGER,
|
||||
birthday TIMESTAMP,
|
||||
area_id INTEGER,
|
||||
mark VARCHAR(255),
|
||||
point INTEGER,
|
||||
tag_ids VARCHAR(255),
|
||||
level_id BIGINT,
|
||||
experience INTEGER,
|
||||
group_id BIGINT,
|
||||
tenant_id BIGINT NOT NULL DEFAULT 0,
|
||||
creator VARCHAR(64) NOT NULL DEFAULT '',
|
||||
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updater VARCHAR(64) NOT NULL DEFAULT '',
|
||||
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
)
|
||||
""");
|
||||
} catch (SQLException exception) {
|
||||
throw new AssertionError("创建 test-local member_user fixture 表失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 连接配置(密码仅 env、URL 脱敏、_test 守卫) ====================
|
||||
|
||||
private static CompletedApprovalSettings settings() {
|
||||
if (cachedSettings == null) {
|
||||
cachedSettings = CompletedApprovalSettings.fromPropertiesAndEnvironment();
|
||||
}
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
private static void cleanSchema(Flyway flyway, CompletedApprovalSettings settings) {
|
||||
try {
|
||||
flyway.clean();
|
||||
} catch (RuntimeException exception) {
|
||||
throw sanitizedFlywayFailure("Flyway clean 失败", settings, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static MigrateResult migrateSchema(Flyway flyway, CompletedApprovalSettings settings) {
|
||||
try {
|
||||
return flyway.migrate();
|
||||
} catch (RuntimeException exception) {
|
||||
throw sanitizedFlywayFailure("Flyway migrate 失败", settings, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureTestDatabaseExists(CompletedApprovalSettings settings) {
|
||||
String databaseName = jdbcDatabaseName(settings.jdbcUrl());
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
maintenanceJdbcUrl(settings.jdbcUrl()), settings.jdbcUser(), settings.jdbcPassword());
|
||||
Statement statement = connection.createStatement()) {
|
||||
connection.setAutoCommit(true);
|
||||
// 专属 _test 库不存在时自动创建,避免验收环境缺库导致真实 PG 证据中断。
|
||||
statement.execute("CREATE DATABASE " + quotedIdentifier(databaseName));
|
||||
} catch (SQLException exception) {
|
||||
if ("42P04".equals(exception.getSQLState())) {
|
||||
return;
|
||||
}
|
||||
throw sanitizedSqlFailure("创建 PostgreSQL _test 数据库失败", settings, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static AssertionError sanitizedFlywayFailure(String action, CompletedApprovalSettings settings,
|
||||
RuntimeException exception) {
|
||||
String sanitizedMessage = Objects.toString(exception.getMessage(), "")
|
||||
.replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl()))
|
||||
.replace("for user '" + settings.jdbcUser() + "'", "for user '<user-redacted>'");
|
||||
return new AssertionError(action + ": " + sanitizedMessage);
|
||||
}
|
||||
|
||||
private static AssertionError sanitizedSqlFailure(String action, CompletedApprovalSettings settings,
|
||||
SQLException exception) {
|
||||
String sanitizedMessage = Objects.toString(exception.getMessage(), "")
|
||||
.replace(settings.jdbcUrl(), maskedUrl(settings.jdbcUrl()))
|
||||
.replace(settings.jdbcUser(), "<user-redacted>");
|
||||
return new AssertionError(action + ": " + sanitizedMessage);
|
||||
}
|
||||
|
||||
private static String requiredProperty(String name) {
|
||||
String value = System.getProperty(name);
|
||||
assertTrue(value != null && !value.isBlank(), "缺少必需系统属性: " + name);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String requiredPasswordEnvironment() {
|
||||
String password = firstNonBlankEnvironment("P1R_ACCOUNT_EXPORT_COMPLETED_PASSWORD",
|
||||
"P1R_ACCOUNT_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD");
|
||||
assertTrue(password != null,
|
||||
"缺少必需环境变量: P1R_ACCOUNT_EXPORT_COMPLETED_PASSWORD、P1R_ACCOUNT_COMPLETED_PASSWORD、"
|
||||
+ "P1R_FLYWAY_PASSWORD 或 MUSE_POSTGRES_PASSWORD");
|
||||
return password;
|
||||
}
|
||||
|
||||
private static String firstNonBlankEnvironment(String... names) {
|
||||
// 数据库密码只能来自环境变量,避免 Surefire XML 或 JVM 参数泄露。
|
||||
for (String name : names) {
|
||||
String value = System.getenv(name);
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void assertNoPasswordSystemProperties() {
|
||||
Properties properties = System.getProperties();
|
||||
List<String> passwordProperties = properties.stringPropertyNames().stream()
|
||||
.filter(P1rAccountExportDownloadClusterCompletedApprovalIT::isForbiddenPasswordSystemProperty)
|
||||
.sorted()
|
||||
.toList();
|
||||
assertTrue(passwordProperties.isEmpty(),
|
||||
"数据库密码不能通过 JVM system property 传入: " + passwordProperties);
|
||||
}
|
||||
|
||||
private static boolean isForbiddenPasswordSystemProperty(String name) {
|
||||
String normalized = name.toLowerCase(Locale.ROOT);
|
||||
return normalized.contains("password")
|
||||
&& (normalized.startsWith("p1r.")
|
||||
|| normalized.startsWith("p1r_")
|
||||
|| normalized.contains(".flyway.")
|
||||
|| normalized.contains(".account.")
|
||||
|| normalized.contains(".datasource."));
|
||||
}
|
||||
|
||||
private static void assertNoCredentialQuery(String url) {
|
||||
int queryStart = url.indexOf('?');
|
||||
if (queryStart < 0) {
|
||||
return;
|
||||
}
|
||||
String query = url.substring(queryStart + 1);
|
||||
for (String parameter : query.split("&")) {
|
||||
String key = parameter;
|
||||
int equalsStart = key.indexOf('=');
|
||||
if (equalsStart >= 0) {
|
||||
key = key.substring(0, equalsStart);
|
||||
}
|
||||
assertFalse(isCredentialQueryKey(key),
|
||||
"p1r.flyway.url 不能携带凭据 query 参数;请通过用户名属性和密码环境变量传入");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isCredentialQueryKey(String rawKey) {
|
||||
String key = rawKey.trim().toLowerCase(Locale.ROOT).replace('-', '_');
|
||||
return CREDENTIAL_QUERY_KEYS.contains(key)
|
||||
|| key.endsWith("_token")
|
||||
|| key.endsWith("_secret")
|
||||
|| key.endsWith("_password");
|
||||
}
|
||||
|
||||
private static void assertTestDatabaseUrl(String url) {
|
||||
String databaseName = jdbcDatabaseName(url);
|
||||
assertTrue(databaseName.endsWith("_test"),
|
||||
"p1r.flyway.url 必须指向 _test 后缀隔离库,避免清理非测试库: " + maskedUrl(url));
|
||||
}
|
||||
|
||||
private static String resolveMuseSqlLocation(String requestedLocations) {
|
||||
assertEquals("filesystem:sql/muse", requestedLocations,
|
||||
"P1R Account 导出/下载簇 completed approval IT 要求显式使用 filesystem:sql/muse");
|
||||
Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath();
|
||||
String relativeLocation = requestedLocations.substring("filesystem:".length());
|
||||
for (Path cursor = current; cursor != null; cursor = cursor.getParent()) {
|
||||
Path candidate = cursor.resolve(relativeLocation);
|
||||
if (Files.isDirectory(candidate)) {
|
||||
return "filesystem:" + candidate;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("无法从当前目录向上找到 sql/muse: " + current);
|
||||
}
|
||||
|
||||
private static String jdbcDatabaseName(String url) {
|
||||
String urlWithoutQuery = jdbcUrlWithoutQuery(url);
|
||||
int databaseStart = urlWithoutQuery.lastIndexOf('/');
|
||||
assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1,
|
||||
"p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url));
|
||||
return urlWithoutQuery.substring(databaseStart + 1);
|
||||
}
|
||||
|
||||
private static String jdbcUrlWithoutQuery(String url) {
|
||||
int queryStart = url.indexOf('?');
|
||||
return queryStart < 0 ? url : url.substring(0, queryStart);
|
||||
}
|
||||
|
||||
private static String maintenanceJdbcUrl(String url) {
|
||||
String urlWithoutQuery = jdbcUrlWithoutQuery(url);
|
||||
int databaseStart = urlWithoutQuery.lastIndexOf('/');
|
||||
assertTrue(databaseStart >= 0 && databaseStart < urlWithoutQuery.length() - 1,
|
||||
"p1r.flyway.url 必须包含真实数据库名: " + maskedUrl(url));
|
||||
String querySuffix = url.indexOf('?') < 0 ? "" : url.substring(url.indexOf('?'));
|
||||
return urlWithoutQuery.substring(0, databaseStart + 1) + "postgres" + querySuffix;
|
||||
}
|
||||
|
||||
private static String quotedIdentifier(String identifier) {
|
||||
assertTrue(identifier.matches("[A-Za-z0-9_]+"),
|
||||
"测试数据库名只能包含字母、数字和下划线: " + identifier);
|
||||
return "\"" + identifier.replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
|
||||
private static String maskedUrl(String url) {
|
||||
String urlWithoutQuery = jdbcUrlWithoutQuery(url);
|
||||
int databaseStart = urlWithoutQuery.lastIndexOf('/');
|
||||
if (databaseStart < 0) {
|
||||
return maskJdbcHost(urlWithoutQuery) + maskedQuerySuffix(url);
|
||||
}
|
||||
String prefix = urlWithoutQuery.substring(0, databaseStart + 1);
|
||||
String database = urlWithoutQuery.substring(databaseStart + 1);
|
||||
return maskJdbcHost(prefix) + database + maskedQuerySuffix(url);
|
||||
}
|
||||
|
||||
private static String maskedQuerySuffix(String url) {
|
||||
return url.indexOf('?') < 0 ? "" : "?<query-redacted>";
|
||||
}
|
||||
|
||||
private static String maskJdbcHost(String urlPart) {
|
||||
return urlPart.replaceAll("//([^:/?#]+)", "//<host>");
|
||||
}
|
||||
|
||||
private static void redactFlywaySystemProperties(String url, String user) {
|
||||
captureOriginalFlywaySystemProperties();
|
||||
System.setProperty("p1r.flyway.url", maskedUrl(url));
|
||||
System.setProperty("p1r.flyway.user", user == null || user.isBlank() ? "<user-redacted>" : "<user-redacted>");
|
||||
}
|
||||
|
||||
private static void captureOriginalFlywaySystemProperties() {
|
||||
if (originalFlywayPropertiesCaptured) {
|
||||
return;
|
||||
}
|
||||
// 该 IT 会为日志脱敏 p1r.flyway.*;先保存原值,避免同一 Surefire JVM 中污染后续 Flyway IT。
|
||||
originalFlywayUrlSystemProperty = System.getProperty("p1r.flyway.url");
|
||||
originalFlywayUserSystemProperty = System.getProperty("p1r.flyway.user");
|
||||
originalFlywayPropertiesCaptured = true;
|
||||
}
|
||||
|
||||
private static void restoreOriginalFlywaySystemProperties() {
|
||||
if (!originalFlywayPropertiesCaptured) {
|
||||
return;
|
||||
}
|
||||
// 恢复用户传入的原始连接属性,让组合运行的 Flyway 验收测试继续读取真实 _test 库地址。
|
||||
restoreSystemProperty("p1r.flyway.url", originalFlywayUrlSystemProperty);
|
||||
restoreSystemProperty("p1r.flyway.user", originalFlywayUserSystemProperty);
|
||||
}
|
||||
|
||||
private static void restoreSystemProperty(String name, String value) {
|
||||
if (value == null) {
|
||||
System.clearProperty(name);
|
||||
return;
|
||||
}
|
||||
System.setProperty(name, value);
|
||||
}
|
||||
|
||||
private static void silenceFlywayInfoLogs() {
|
||||
try {
|
||||
Object flywayLogger = LoggerFactory.getLogger("org.flywaydb");
|
||||
Class<?> levelClass = Class.forName("ch.qos.logback.classic.Level");
|
||||
Object warnLevel = levelClass.getField("WARN").get(null);
|
||||
flywayLogger.getClass().getMethod("setLevel", levelClass).invoke(flywayLogger, warnLevel);
|
||||
} catch (ReflectiveOperationException | LinkageError ignored) {
|
||||
// 日志实现不是 logback 时不影响迁移验收;测试自身仍只输出脱敏 URL。
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface CheckedOperation {
|
||||
|
||||
void run() throws Exception;
|
||||
}
|
||||
|
||||
private record AccountFactSnapshot(List<String> memberUsers,
|
||||
List<String> exportTasks,
|
||||
List<String> downloadCredentials,
|
||||
List<String> securityEvents,
|
||||
List<String> commands,
|
||||
List<String> audits) {
|
||||
}
|
||||
|
||||
private record CompletedApprovalSettings(String jdbcUrl,
|
||||
String jdbcUser,
|
||||
String jdbcPassword,
|
||||
String flywayLocations) {
|
||||
|
||||
static CompletedApprovalSettings fromPropertiesAndEnvironment() {
|
||||
assertNoPasswordSystemProperties();
|
||||
String url = requiredProperty("p1r.flyway.url");
|
||||
String user = requiredProperty("p1r.flyway.user");
|
||||
String password = requiredPasswordEnvironment();
|
||||
String locations = requiredProperty("p1r.flyway.locations");
|
||||
assertNoCredentialQuery(url);
|
||||
assertTestDatabaseUrl(url);
|
||||
return new CompletedApprovalSettings(url, user, password, locations);
|
||||
}
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@ImportAutoConfiguration({
|
||||
JacksonAutoConfiguration.class,
|
||||
HttpMessageConvertersAutoConfiguration.class,
|
||||
DataSourceAutoConfiguration.class,
|
||||
DataSourceTransactionManagerAutoConfiguration.class,
|
||||
JdbcTemplateAutoConfiguration.class,
|
||||
TransactionAutoConfiguration.class,
|
||||
RestTemplateAutoConfiguration.class,
|
||||
WebMvcAutoConfiguration.class,
|
||||
MuseDataSourceAutoConfiguration.class,
|
||||
MuseMybatisAutoConfiguration.class,
|
||||
MybatisPlusAutoConfiguration.class,
|
||||
MybatisPlusJoinAutoConfiguration.class,
|
||||
MuseWebAutoConfiguration.class
|
||||
})
|
||||
@Import({
|
||||
AppAccountExportController.class,
|
||||
AccountExportServiceImpl.class,
|
||||
AccountCommandServiceImpl.class,
|
||||
AccountAuditServiceImpl.class,
|
||||
SpringUtil.class
|
||||
})
|
||||
static class ExportDownloadClusterCompletedApprovalConfiguration {
|
||||
|
||||
@Bean
|
||||
AccountFileServiceFacade accountFileServiceFacade() {
|
||||
// 单体默认实现:FileService 未接入,createExportTask/downloadExportPackage 均 unavailable。
|
||||
return new UnavailableAccountFileServiceFacade();
|
||||
}
|
||||
|
||||
@Bean
|
||||
TenantLineInnerInterceptor tenantLineInnerInterceptor(MybatisPlusInterceptor interceptor) {
|
||||
// member 模块依赖租户行拦截器:查询自动按 tenant 过滤、插入自动补 tenant_id。
|
||||
TenantLineInnerInterceptor inner = new TenantLineInnerInterceptor(
|
||||
new TenantDatabaseInterceptor(new TenantProperties()));
|
||||
MyBatisUtils.addInterceptor(interceptor, inner, 0);
|
||||
return inner;
|
||||
}
|
||||
|
||||
@Bean
|
||||
ApiErrorLogCommonApi apiErrorLogCommonApi() {
|
||||
return new ApiErrorLogCommonApi() {
|
||||
@Override
|
||||
public CommonResult<Boolean> createApiErrorLog(ApiErrorLogCreateReqDTO createDTO) {
|
||||
return CommonResult.success(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user