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:
lili 2026-06-17 00:57:35 -07:00
parent 26e1dd145f
commit ca50d5a162
3 changed files with 813 additions and 3 deletions

View File

@ -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/平台预存红三项全清。**

View File

@ -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;关台账需人工批准,见铁律)。

View File

@ -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);
}
};
}
}
}