test(p1r): 批15 market 管理端资产治理读真实 PG IT(adminList/GetMarketAssets)
P1rMarketAdminAssetReadsCompletedApprovalIT 经真实 /admin-api/muse/market/assets 覆盖 adminListMarketAssets/adminGetMarketAsset(market 模块整组治理 mapper + MarketCommandServiceImpl + MarketEventPublishOutboxServiceImpl,base-package=cn.iocoder.muse.module,admin @PreAuthorize method security)。断言:列表按 tenant 隔离 + assetType/listingStatus/publisherId 过滤 (listingStatus 的 not_listed↔draft 由服务端 toInternalListingStatus/normalizeListingStatus 双向归一)、 publisherName/version(无当前版本→unknown)回填、关联计数(安装/绑定/申诉=0,关联表空);详情按 tenant 隔离返回资产快照 + 授权/安装/绑定/受影响任务计数全 0 + 版本/治理历史/申诉空集合;缺资产/跨租户→ MARKET_ASSET_NOT_EXISTS、非法 API version→MARKET_API_VERSION_UNSUPPORTED、RBAC (muse:market:asset:query)deny→FORBIDDEN,两 op 全程纯读 no-write(资产/命令/治理/来源状态/事件 outbox 表 row_to_json 快照零变更)。真实 PG muse_p1r_market_admin_asset_test 2/2 绿,独立复跑两次均绿。 market 验收债 14→12(下一簇=治理写 previewGovernanceImpact/delist/recall)。同步 docs/mvp/进度总账.md §五B 与 market/.agent。completed 仍由人工批台账。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
26e1dd145f
commit
ca50d5a162
@ -130,7 +130,8 @@
|
||||
- **批12 ✅ 已交付(account 管理端写路收尾 2 op,2026-06-17)**:`P1rAccountAdminWritesCompletedApprovalIT`(真实 PG `muse_p1r_account_admin_writes_test`,3/3 绿,独立复跑两次均绿)覆盖 adminCreateNewApiBinding/adminCreateCallAttributionJob;经真实 `/admin-api/muse/account/users/{userId}/new-api-binding` 与 `/admin-api/muse/account/call-attribution-jobs`(member 模块租户行拦截器 + member_user fixture + admin `@PreAuthorize` method security),断言:adminCreateNewApiBinding 在单体 New-API facade(`UnavailableNewApiAccountFacade`)不可用时**正确 fail-closed**——业务 `@Transactional` 回滚命令预占(0 残留)+ 不写 binding,失败外部调用经 `REQUIRES_NEW` 独立落库一条 `unavailable`,客户端收 `ACCOUNT_NEW_API_UNAVAILABLE`,目标用户不存在/RBAC(`muse:account:new-api:manage`)/API version 守卫 0 写;adminCreateCallAttributionJob 基于已存在 correlationId 外部调用建立 queued 归因 job + 命令(items 暂空)、命令幂等回放(idempotent_hit 不写第二条)、callIds 白名单未命中 BAD_REQUEST、expectedCallRevision 版本冲突 `ACCOUNT_VERSION_CONFLICT`、缺调用记录 `ACCOUNT_INTEGRATION_CALL_NOT_EXISTS`、RBAC(`muse:account:attribution:manage`)/API version fail-closed,失败路径全程 0 写。命令走真实实现,归因来源/审计/配额/用量附带依赖用抛错 mock 反假绿越界。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。
|
||||
- **批13 ✅ 已交付(account 安全事件确认 1 op,2026-06-17)**:`P1rAccountSecurityEventAckCompletedApprovalIT`(真实 PG `muse_p1r_account_security_ack_test`,2/2 绿,独立复跑两次均绿)覆盖 appAcknowledgeSecurityEvent;经真实 `/app-api/muse/account/security-events/{eventId}/acknowledge`(member 模块租户行拦截器 + member_user fixture),断言:属主确认按 owner 校验事件归属、向追加表 muse_account_security_event_ack 追加处理轨迹 + 基础事件 acknowledged 置真 + 命令 + 成功审计,相同 commandId 幂等回放(不追加第二条轨迹);非法 action→BAD_REQUEST、跨 owner→NOT_FOUND(owner 隔离不泄露)、非数字 eventId→NOT_FOUND、缺用户→`ACCOUNT_USER_NOT_EXISTS`、API version 守卫,全程 fail-closed 且 0 写。命令/审计走真实实现,无外部依赖。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。
|
||||
- **批14 ✅ 已交付(account 管理端购买记录读 1 op,account 验收债清零,2026-06-17)**:`P1rAccountAdminPurchaseRecordsCompletedApprovalIT`(真实 PG `muse_p1r_account_admin_purchase_test`,2/2 绿,独立复跑两次均绿)覆盖 adminListPurchaseRecords;经真实 `/admin-api/muse/account/purchase-records`(跨模块:`muse.info.base-package=cn.iocoder.muse.module`,单体内 market 的 `MarketAccountProjectionProvider` 真实判定 purchase 投影可用,读端只读 Account 自有投影表 muse_account_record_projection;Account 投影写端口用抛错 stub 越界反假绿),断言:无 userId→跨用户返回全部购买投影、userId 过滤→仅该用户且状态归一(source_status→admin status)+ 资产信息从投影 title/snapshot 回填(assetType/assetName)、userId+status=completed 命中、status=refunded 该用户无返回空、非法 status→BAD_REQUEST、API version 守卫→`ACCOUNT_API_VERSION_UNSUPPORTED`、RBAC(`muse:account:query`)deny→FORBIDDEN,全程纯读 no-write(member_user + 投影表 row_to_json 快照零变更)。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。
|
||||
- 剩余:content 26(约 15 为 FileService/New-API/SSE fail-closed → 只能验"失败关闭"真实证据)+ market 14 + account **0**(adminListPurchaseRecords 已补证 → account needs_verification 验收债清零,批3/7-14 全部经真实 PG 证据、证据就绪待人工批),按批续推 market 14 / content 26。
|
||||
- **批15 ✅ 已交付(market 管理端资产治理读 2 op,2026-06-17)**:`P1rMarketAdminAssetReadsCompletedApprovalIT`(真实 PG `muse_p1r_market_admin_asset_test`,2/2 绿,独立复跑两次均绿)覆盖 adminListMarketAssets/adminGetMarketAsset;经真实 `/admin-api/muse/market/assets`(market 模块整组治理 mapper + `MarketCommandServiceImpl` + `MarketEventPublishOutboxServiceImpl`,base-package=cn.iocoder.muse.module,admin `@PreAuthorize` method security),断言:列表按 tenant 隔离 + assetType/listingStatus/publisherId 过滤(listingStatus 的 not_listed↔draft 由服务端 toInternalListingStatus/normalizeListingStatus 双向归一)、publisherName/version(无当前版本→unknown)回填、关联计数(安装/绑定/申诉=0,关联表空);详情按 tenant 隔离返回资产快照 + 授权/安装/绑定/受影响任务计数全 0 + 版本/治理历史/申诉空集合;缺资产/跨租户→`MARKET_ASSET_NOT_EXISTS`、非法 API version→`MARKET_API_VERSION_UNSUPPORTED`、RBAC(`muse:market:asset:query`)deny→FORBIDDEN,两 op 全程纯读 no-write(资产/命令/治理/来源状态/事件 outbox 表 row_to_json 快照零变更)。测试内仅对专属 `_test` 库做缺库自动创建,保留 `_test`/密码环境变量/凭据 query 守卫。**证据就绪,completed 待人工批**。
|
||||
- 剩余:content 26(约 15 为 FileService/New-API/SSE fail-closed → 只能验"失败关闭"真实证据)+ market **12**(批15 补管理端资产治理读 adminList/GetMarketAssets;下一簇=治理写 previewGovernanceImpact/delist/recall)+ account **0**(adminListPurchaseRecords 已补证 → account needs_verification 验收债清零,批3/7-14 全部经真实 PG 证据、证据就绪待人工批),按批续推 market 12 / content 26。
|
||||
- ✅ **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 职责)**:市场资产(Work/Agent/KB Asset)/ Publish Draft / Publish Check Snapshot / Review / Listing / License / Purchase / Install / Handoff Token / 授权摘要 / 跳转审计 / Governance Action / Source Status Event。
|
||||
- **边界(不可违反)**:**授权 ≠ 所有权转移**;市场**非**源事实 owner,目标事实回目标 owner;Work 资产仅只读且排除出 install(`INSTALLABLE_ASSET_TYPES=Set.of(agent,knowledge_base)`);守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。
|
||||
- **out-of-scope**:完整电商支付结算 DRM(项目非目标);模板化/参考写入/AI 上下文受 Feature Gate 默认关闭。
|
||||
- **现状**:只读评估 82%;核心不变式已被代码强制。**生产者飞轮已端到端打通并活体证(2026-06-15)**:发布草稿→检查→提交→上架状态可视化→申诉(提交/补充材料/撤回)→申诉态回显(`MarketPublish`+`market-publish.spec.ts` 8/8,见[总账](../../docs/mvp/进度总账.md) §五C)。本会话新增 3 处 app-api 契约:`PublishRecordItem.marketAssetId`(物化资产 id,解申诉 assetId 映射 gap)、`GET /marketplace/appeals`(我的申诉列表)、记录 `appealStatus` 回填(`selectLatestByAssetIdAndUser`)。**2026-06-16 补真实 PG 验收证据**:`P1rMarketPublishProducerCompletedApprovalIT` 覆盖发布生产侧 5 op,真实 PG 3/3 绿;`P1rMarketAdminAppealCompletedApprovalIT` 覆盖管理端申诉 3 op(adminListAppeals/adminGetAppeal/adminResolveAppeal),真实 PG 6/6 绿;`P1rMarketAdminPublishReviewCompletedApprovalIT` 覆盖管理端发布审核 3 op(adminListPublishRequests/adminApprovePublishRequest/adminRejectPublishRequest),真实 PG 6/6 绿;`P1rMarketProducerAppealCompletedApprovalIT` 覆盖生产者申诉写路 3 op(submitAppeal/supplementAppeal/withdrawAppeal,app 入口 + owner 隔离 + 脱敏 + 状态 CAS + 冲突回滚),真实 PG 7/7 绿(独立复跑);证据就绪,completed 仍待人工批准。
|
||||
- **关键风险 / TODO**:① **资产物化只在 admin 审核通过(`markListed`)发生**——submit 阶段 `publish_request.asset_id=draft.id` 占位,故未上架资产无 `muse_market_asset` 行,申诉(`requireAsset`)仅对已物化(曾上架)资产可达;② 申诉证据材料附件上传(`attachmentIds` 当前前端置空);③ 上架后下架/召回的生产者自助入口;④ 验收债剩余 market 14 op 真实 PG IT(关台账需人工批准,见铁律)。
|
||||
- **现状**:只读评估 82%;核心不变式已被代码强制。**生产者飞轮已端到端打通并活体证(2026-06-15)**:发布草稿→检查→提交→上架状态可视化→申诉(提交/补充材料/撤回)→申诉态回显(`MarketPublish`+`market-publish.spec.ts` 8/8,见[总账](../../docs/mvp/进度总账.md) §五C)。本会话新增 3 处 app-api 契约:`PublishRecordItem.marketAssetId`(物化资产 id,解申诉 assetId 映射 gap)、`GET /marketplace/appeals`(我的申诉列表)、记录 `appealStatus` 回填(`selectLatestByAssetIdAndUser`)。**2026-06-16 补真实 PG 验收证据**:`P1rMarketPublishProducerCompletedApprovalIT` 覆盖发布生产侧 5 op,真实 PG 3/3 绿;`P1rMarketAdminAppealCompletedApprovalIT` 覆盖管理端申诉 3 op(adminListAppeals/adminGetAppeal/adminResolveAppeal),真实 PG 6/6 绿;`P1rMarketAdminPublishReviewCompletedApprovalIT` 覆盖管理端发布审核 3 op(adminListPublishRequests/adminApprovePublishRequest/adminRejectPublishRequest),真实 PG 6/6 绿;`P1rMarketProducerAppealCompletedApprovalIT` 覆盖生产者申诉写路 3 op(submitAppeal/supplementAppeal/withdrawAppeal,app 入口 + owner 隔离 + 脱敏 + 状态 CAS + 冲突回滚),真实 PG 7/7 绿(独立复跑)。**2026-06-17 续补**:`P1rMarketAdminAssetReadsCompletedApprovalIT` 覆盖管理端资产治理读 2 op(adminListMarketAssets/adminGetMarketAsset:tenant 隔离 + assetType/listingStatus(not_listed↔draft 归一)/publisherId 过滤、详情资产快照 + 授权/安装/绑定/受影响任务计数全 0(关联表空)、缺资产/跨租户→MARKET_ASSET_NOT_EXISTS、API version/RBAC(muse:market:asset:query)fail-closed、纯读 no-write),真实 PG 2/2 绿(独立复跑);证据就绪,completed 仍待人工批准。
|
||||
- **关键风险 / TODO**:① **资产物化只在 admin 审核通过(`markListed`)发生**——submit 阶段 `publish_request.asset_id=draft.id` 占位,故未上架资产无 `muse_market_asset` 行,申诉(`requireAsset`)仅对已物化(曾上架)资产可达;② 申诉证据材料附件上传(`attachmentIds` 当前前端置空);③ 上架后下架/召回的生产者自助入口;④ 验收债剩余 market 12 op 真实 PG IT(批15 已补管理端资产治理读 adminList/GetMarketAssets 2 op;下一簇=治理写 previewGovernanceImpact→delist/recall;关台账需人工批准,见铁律)。
|
||||
|
||||
@ -0,0 +1,809 @@
|
||||
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.security.core.LoginUser;
|
||||
import cn.iocoder.muse.framework.security.core.service.SecurityFrameworkService;
|
||||
import cn.iocoder.muse.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.muse.framework.web.config.MuseWebAutoConfiguration;
|
||||
import cn.iocoder.muse.module.market.application.muse.AdminMarketGovernanceServiceImpl;
|
||||
import cn.iocoder.muse.module.market.application.muse.MarketCommandServiceImpl;
|
||||
import cn.iocoder.muse.module.market.application.muse.MarketEventPublishOutboxServiceImpl;
|
||||
import cn.iocoder.muse.module.market.controller.admin.muse.AdminMuseMarketAssetController;
|
||||
import cn.iocoder.muse.module.market.framework.config.MuseMarketEventsProperties;
|
||||
import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
|
||||
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.context.properties.EnableConfigurationProperties;
|
||||
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.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
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.file.Files;
|
||||
import java.nio.file.Path;
|
||||
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.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static cn.iocoder.muse.framework.common.exception.enums.GlobalErrorCodeConstants.FORBIDDEN;
|
||||
import static cn.iocoder.muse.module.market.enums.ErrorCodeConstants.MARKET_API_VERSION_UNSUPPORTED;
|
||||
import static cn.iocoder.muse.module.market.enums.ErrorCodeConstants.MARKET_ASSET_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.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* P1R Market 管理端资产治理读 completed approval:adminListMarketAssets / adminGetMarketAsset 的 HTTP + 真实 PostgreSQL 证据。
|
||||
*
|
||||
* <p>本测试只连接显式传入的 PostgreSQL {@code _test} 隔离库,经真实 {@code /admin-api/muse/market/assets}
|
||||
* 入口进入 Controller,并启用 Spring method security 校验 {@code @PreAuthorize("@ss.hasPermission('muse:market:asset:query')")}。
|
||||
* 断言:管理端列表按 tenant 隔离 + assetType/listingStatus/publisherId 过滤(listingStatus 的 not_listed↔draft 双向归一)、
|
||||
* 详情按 tenant 隔离返回资产快照 + 关联计数(授权/安装/绑定/受影响任务全 0,因关联表为空)、缺资产 / 跨租户 / API version /
|
||||
* RBAC fail-closed,两 op 全程纯读 no-write(市场资产/命令/治理/事件 outbox 表 row_to_json 快照零变更)。</p>
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = P1rMarketAdminAssetReadsCompletedApprovalIT.AdminAssetReadsCompletedApprovalConfiguration.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.MOCK
|
||||
)
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class P1rMarketAdminAssetReadsCompletedApprovalIT {
|
||||
|
||||
private static final Long TENANT_ID = 100L;
|
||||
private static final Long OTHER_TENANT_ID = 200L;
|
||||
private static final Long ADMIN_USER_ID = 9001L;
|
||||
private static final Long PUBLISHER_A_ID = 8801L;
|
||||
private static final Long PUBLISHER_B_ID = 8802L;
|
||||
private static final Long PUBLISHER_C_ID = 8803L;
|
||||
private static final String API_VERSION = "1";
|
||||
private static final String QUERY_PERMISSION = "muse:market:asset:query";
|
||||
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;
|
||||
@Autowired
|
||||
private TestSecurityFrameworkService securityFrameworkService;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void registerCompletedApprovalProperties(DynamicPropertyRegistry registry) {
|
||||
CompletedApprovalSettings settings = settings();
|
||||
redactFlywaySystemProperties(settings.jdbcUrl(), settings.jdbcUser());
|
||||
registry.add("spring.application.name", () -> "p1r-market-admin-asset-reads-completed-approval-it");
|
||||
// market 资产治理服务依赖整组 market mapper + command/event outbox,需扫描整个 muse.module。
|
||||
registry.add("muse.info.base-package", () -> "cn.iocoder.muse.module");
|
||||
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 migrateMarketSchema() {
|
||||
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);
|
||||
// Market 治理命令/动作/影响/来源状态事件在 V15;断言迁移已覆盖到含治理 schema。
|
||||
assertTrue(result.migrationsExecuted >= 15,
|
||||
"Market 管理端资产治理读 completed approval 至少要迁移到含 V15 治理 schema,实际: "
|
||||
+ result.migrationsExecuted);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();
|
||||
resetMarketTables();
|
||||
setRuntimeContext(ADMIN_USER_ID, UserTypeEnum.ADMIN.getValue());
|
||||
securityFrameworkService.allowPermissions();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void restoreFlywaySystemProperties() {
|
||||
restoreOriginalFlywaySystemProperties();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_rejectUnsafeDatabaseConfigurationInputs() {
|
||||
System.setProperty("p1r.market.admin-asset.password", "must-not-be-used");
|
||||
try {
|
||||
AssertionError error = assertThrows(AssertionError.class,
|
||||
P1rMarketAdminAssetReadsCompletedApprovalIT::assertNoPasswordSystemProperties);
|
||||
assertTrue(error.getMessage().contains("p1r.market.admin-asset.password"),
|
||||
"拒绝 JVM password system property 时必须指出属性名");
|
||||
} finally {
|
||||
System.clearProperty("p1r.market.admin-asset.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_adminListAndGetMarketAssetsWithTenantFilterRbacAndNoWrite() throws Exception {
|
||||
long assetA = seedAsset(TENANT_ID, "作品A", "work", PUBLISHER_A_ID, "listed");
|
||||
long assetB = seedAsset(TENANT_ID, "智能体B", "agent", PUBLISHER_B_ID, "draft");
|
||||
// 跨租户资产:同样 listed,但归属 OTHER_TENANT_ID,管理端在 TENANT_ID 上下文中绝不可见。
|
||||
seedAsset(OTHER_TENANT_ID, "他租户作品C", "work", PUBLISHER_C_ID, "listed");
|
||||
|
||||
// 列表无过滤 → 仅返回当前租户的两条资产(跨租户 C 被隔离)。
|
||||
assertNoMarketMutationDuring("adminListMarketAssets 是纯读 operation",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.param("pageNo", "1")
|
||||
.param("pageSize", "10"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(2)));
|
||||
assertEquals(QUERY_PERMISSION, securityFrameworkService.lastPermission(),
|
||||
"adminListMarketAssets 必须经 method security 校验 asset query 权限");
|
||||
|
||||
// listingStatus=listed → 仅 A,归一渲染 listed,publisherName/version 回填。
|
||||
assertNoMarketMutationDuring("adminListMarketAssets listingStatus=listed 过滤",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.param("listingStatus", "listed"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.list[0].assetId").value(String.valueOf(assetA)))
|
||||
.andExpect(jsonPath("$.data.list[0].name").value("作品A"))
|
||||
.andExpect(jsonPath("$.data.list[0].assetType").value("work"))
|
||||
.andExpect(jsonPath("$.data.list[0].publisherId").value(String.valueOf(PUBLISHER_A_ID)))
|
||||
.andExpect(jsonPath("$.data.list[0].publisherName").value("publisher-" + PUBLISHER_A_ID))
|
||||
.andExpect(jsonPath("$.data.list[0].version").value("unknown"))
|
||||
.andExpect(jsonPath("$.data.list[0].listingStatus").value("listed"))
|
||||
.andExpect(jsonPath("$.data.list[0].installCount").value(0))
|
||||
.andExpect(jsonPath("$.data.list[0].bindCount").value(0))
|
||||
.andExpect(jsonPath("$.data.list[0].appealCount").value(0)));
|
||||
|
||||
// listingStatus=not_listed → 服务端 toInternalListingStatus 归一为 draft,命中 B,渲染回 not_listed。
|
||||
assertNoMarketMutationDuring("adminListMarketAssets listingStatus=not_listed 归一为 draft",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.param("listingStatus", "not_listed"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.list[0].assetId").value(String.valueOf(assetB)))
|
||||
.andExpect(jsonPath("$.data.list[0].listingStatus").value("not_listed")));
|
||||
|
||||
// assetType=work → 仅当前租户的 A(跨租户 C 同为 work 但被租户隔离)。
|
||||
assertNoMarketMutationDuring("adminListMarketAssets assetType 过滤 + 租户隔离",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.param("assetType", "work"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.list[0].assetId").value(String.valueOf(assetA))));
|
||||
|
||||
// publisherId 过滤 → 仅该发布者的 B。
|
||||
assertNoMarketMutationDuring("adminListMarketAssets publisherId 过滤",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets")
|
||||
.header("X-API-Version", API_VERSION)
|
||||
.param("publisherId", String.valueOf(PUBLISHER_B_ID)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.total").value(1))
|
||||
.andExpect(jsonPath("$.data.list[0].assetId").value(String.valueOf(assetB))));
|
||||
|
||||
// 详情 → 资产快照 + 关联计数(关联表为空,授权/安装/绑定/受影响任务全 0),申诉/版本/治理历史为空集合。
|
||||
assertNoMarketMutationDuring("adminGetMarketAsset 是纯读 operation",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets/{assetId}", assetA)
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.assetId").value(String.valueOf(assetA)))
|
||||
.andExpect(jsonPath("$.data.name").value("作品A"))
|
||||
.andExpect(jsonPath("$.data.assetType").value("work"))
|
||||
.andExpect(jsonPath("$.data.publisherId").value(String.valueOf(PUBLISHER_A_ID)))
|
||||
.andExpect(jsonPath("$.data.publisherName").value("publisher-" + PUBLISHER_A_ID))
|
||||
.andExpect(jsonPath("$.data.listingStatus").value("listed"))
|
||||
.andExpect(jsonPath("$.data.version").value("unknown"))
|
||||
.andExpect(jsonPath("$.data.authorizationCount").value(0))
|
||||
.andExpect(jsonPath("$.data.installCount").value(0))
|
||||
.andExpect(jsonPath("$.data.bindCount").value(0))
|
||||
.andExpect(jsonPath("$.data.affectedTaskCount").value(0))
|
||||
.andExpect(jsonPath("$.data.versions").isEmpty())
|
||||
.andExpect(jsonPath("$.data.governanceHistory").isEmpty())
|
||||
.andExpect(jsonPath("$.data.appeals").isEmpty()));
|
||||
|
||||
// 详情缺资产 → MARKET_ASSET_NOT_EXISTS,不写库。
|
||||
assertNoMarketMutationDuring("adminGetMarketAsset 缺资产必须 fail-closed",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets/{assetId}", 999_999_999L)
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(MARKET_ASSET_NOT_EXISTS.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// 详情跨租户 → 用 OTHER_TENANT_ID 上下文取当前租户 A 资产应判不存在(租户隔离不泄露)。
|
||||
setRuntimeContext(ADMIN_USER_ID, UserTypeEnum.ADMIN.getValue(), OTHER_TENANT_ID);
|
||||
assertNoMarketMutationDuring("adminGetMarketAsset 跨租户必须判不存在",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets/{assetId}", assetA)
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(MARKET_ASSET_NOT_EXISTS.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
setRuntimeContext(ADMIN_USER_ID, UserTypeEnum.ADMIN.getValue());
|
||||
|
||||
// API version 非法 → 列表/详情均拒绝,不写库。
|
||||
assertNoMarketMutationDuring("adminListMarketAssets 非法 API version 必须拒绝",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets")
|
||||
.header("X-API-Version", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(MARKET_API_VERSION_UNSUPPORTED.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
assertNoMarketMutationDuring("adminGetMarketAsset 非法 API version 必须拒绝",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets/{assetId}", assetA)
|
||||
.header("X-API-Version", "0"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(MARKET_API_VERSION_UNSUPPORTED.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
|
||||
// RBAC 拒绝 → 列表/详情 forbidden,不写库,不泄露。
|
||||
securityFrameworkService.denyPermissions();
|
||||
assertNoMarketMutationDuring("adminListMarketAssets 无 asset query 权限必须 RBAC fail-closed",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets")
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(FORBIDDEN.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
assertNoMarketMutationDuring("adminGetMarketAsset 无 asset query 权限必须 RBAC fail-closed",
|
||||
() -> mockMvc.perform(get("/admin-api/muse/market/assets/{assetId}", assetA)
|
||||
.header("X-API-Version", API_VERSION))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(FORBIDDEN.getCode()))
|
||||
.andExpect(jsonPath("$.data").doesNotExist()));
|
||||
}
|
||||
|
||||
// ==================== 运行期上下文 ====================
|
||||
|
||||
private void setRuntimeContext(Long userId, Integer userType) {
|
||||
setRuntimeContext(userId, userType, TENANT_ID);
|
||||
}
|
||||
|
||||
private void setRuntimeContext(Long userId, Integer userType, Long tenantId) {
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
LoginUser loginUser = new LoginUser();
|
||||
loginUser.setId(userId);
|
||||
loginUser.setUserType(userType);
|
||||
loginUser.setTenantId(tenantId);
|
||||
loginUser.setVisitTenantId(tenantId);
|
||||
SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
|
||||
}
|
||||
|
||||
// ==================== 种子 / 重置 ====================
|
||||
|
||||
private void resetMarketTables() {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
// 读端仅触达资产 + 关联计数/治理/命令/事件 outbox 表;全部重置,使 no-write 断言能识别任何误写。
|
||||
statement.execute("""
|
||||
TRUNCATE TABLE
|
||||
muse_market_asset,
|
||||
muse_market_asset_version,
|
||||
muse_market_command,
|
||||
muse_market_governance_preview,
|
||||
muse_market_governance_action,
|
||||
muse_market_governance_impact,
|
||||
muse_market_source_status_event,
|
||||
muse_market_event_publish_outbox
|
||||
RESTART IDENTITY CASCADE
|
||||
""");
|
||||
} catch (SQLException exception) {
|
||||
throw new AssertionError("重置 Market 资产读相关表失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private long seedAsset(Long tenantId, String name, String assetType, Long publisherId, String listingStatus) {
|
||||
// id 为 IDENTITY,不插入;返回生成的资产 id 供详情/列表断言。current_version_id 留空 → version 渲染 "unknown"。
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
PreparedStatement statement = connection.prepareStatement("""
|
||||
INSERT INTO muse_market_asset(name, asset_type, publisher_id, listing_status, license_type, status, tenant_id)
|
||||
VALUES (?, ?, ?, ?, 'standard', ?, ?)
|
||||
RETURNING id
|
||||
""")) {
|
||||
statement.setString(1, name);
|
||||
statement.setString(2, assetType);
|
||||
statement.setLong(3, publisherId);
|
||||
statement.setString(4, listingStatus);
|
||||
statement.setString(5, listingStatus);
|
||||
statement.setLong(6, tenantId);
|
||||
try (ResultSet resultSet = statement.executeQuery()) {
|
||||
assertTrue(resultSet.next(), "seed muse_market_asset 必须返回生成的 id");
|
||||
return resultSet.getLong(1);
|
||||
}
|
||||
} catch (SQLException exception) {
|
||||
throw new AssertionError("seed muse_market_asset 失败", exception);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 无突变断言(反假绿) ====================
|
||||
|
||||
private void assertNoMarketMutationDuring(String message, CheckedOperation operation) throws Exception {
|
||||
MarketFactSnapshot before = marketFactSnapshot();
|
||||
operation.run();
|
||||
assertEquals(before, marketFactSnapshot(), message + ":不能新增、删除或更新任何 Market fact");
|
||||
}
|
||||
|
||||
private MarketFactSnapshot marketFactSnapshot() {
|
||||
return new MarketFactSnapshot(
|
||||
tableSnapshot("muse_market_asset", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_command", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_governance_preview", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_governance_action", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_governance_impact", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_source_status_event", "tenant_id, id"),
|
||||
tableSnapshot("muse_market_event_publish_outbox", "tenant_id, id")
|
||||
);
|
||||
}
|
||||
|
||||
private List<String> tableSnapshot(String tableName, String orderBy) {
|
||||
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;
|
||||
} catch (SQLException exception) {
|
||||
throw new AssertionError("读取 " + tableName + " 快照失败", 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_MARKET_ADMIN_ASSET_COMPLETED_PASSWORD",
|
||||
"P1R_MARKET_COMPLETED_PASSWORD", "P1R_FLYWAY_PASSWORD", "MUSE_POSTGRES_PASSWORD");
|
||||
assertTrue(password != null,
|
||||
"缺少必需环境变量: P1R_MARKET_ADMIN_ASSET_COMPLETED_PASSWORD、P1R_MARKET_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(P1rMarketAdminAssetReadsCompletedApprovalIT::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(".market.")
|
||||
|| 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 Market 管理端资产治理读 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 MarketFactSnapshot(List<String> assets,
|
||||
List<String> commands,
|
||||
List<String> governancePreviews,
|
||||
List<String> governanceActions,
|
||||
List<String> governanceImpacts,
|
||||
List<String> sourceStatusEvents,
|
||||
List<String> eventOutbox) {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
static class TestSecurityFrameworkService implements SecurityFrameworkService {
|
||||
|
||||
private final AtomicBoolean permissionAllowed = new AtomicBoolean(true);
|
||||
private final AtomicReference<String> lastPermission = new AtomicReference<>();
|
||||
|
||||
void allowPermissions() {
|
||||
permissionAllowed.set(true);
|
||||
lastPermission.set(null);
|
||||
}
|
||||
|
||||
void denyPermissions() {
|
||||
permissionAllowed.set(false);
|
||||
lastPermission.set(null);
|
||||
}
|
||||
|
||||
String lastPermission() {
|
||||
return lastPermission.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasPermission(String permission) {
|
||||
lastPermission.set(permission);
|
||||
return permissionAllowed.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasAnyPermissions(String... permissions) {
|
||||
lastPermission.set(permissions == null || permissions.length == 0 ? null : permissions[0]);
|
||||
return permissionAllowed.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasRole(String role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasAnyRoles(String... roles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasScope(String scope) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasAnyScopes(String... scope) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@EnableMethodSecurity(securedEnabled = true)
|
||||
@EnableConfigurationProperties(MuseMarketEventsProperties.class)
|
||||
@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({
|
||||
AdminMuseMarketAssetController.class,
|
||||
AdminMarketGovernanceServiceImpl.class,
|
||||
MarketCommandServiceImpl.class,
|
||||
MarketEventPublishOutboxServiceImpl.class,
|
||||
SpringUtil.class
|
||||
})
|
||||
static class AdminAssetReadsCompletedApprovalConfiguration {
|
||||
|
||||
@Bean("ss")
|
||||
TestSecurityFrameworkService securityFrameworkService() {
|
||||
return new TestSecurityFrameworkService();
|
||||
}
|
||||
|
||||
@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