From a3ab6253c133a74258677e8c38fb9d964c0f7911 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Mon, 26 Oct 2026 21:48:07 -0700 Subject: [PATCH 1/8] fix: restore management and agent CI contracts --- .../src/components/LanguageSwitcher.test.ts | 48 +++++++++++++++++++ .../components/theme-color-contract.test.ts | 2 +- .../data/repository_corpus_manifest.json | 4 +- 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 apps/management/src/components/LanguageSwitcher.test.ts diff --git a/apps/management/src/components/LanguageSwitcher.test.ts b/apps/management/src/components/LanguageSwitcher.test.ts new file mode 100644 index 000000000..c103d3001 --- /dev/null +++ b/apps/management/src/components/LanguageSwitcher.test.ts @@ -0,0 +1,48 @@ +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { Check } from '@lucide/vue' +import LanguageSwitcher from './LanguageSwitcher.vue' + +const { setLocale } = vi.hoisted(() => ({ setLocale: vi.fn() })) + +vi.mock('@/composables/useLocale', () => ({ + useLocale: () => ({ + availableLocales: [ + { code: 'en', nativeName: 'English' }, + { code: 'zh-CN', nativeName: '简体中文' }, + ], + setLocale, + isCurrentLocale: (code: string) => code === 'en', + }), +})) + +describe('LanguageSwitcher', () => { + it('changes locale on the menu selection event and marks the current locale', async () => { + // Reka emits select for pointer and keyboard activation. Verify the consumer + // listens to that public event, without replacing it with a click handler. + const item = defineComponent({ + emits: ['select'], + template: '
', + }) + const container = { template: '
' } + const wrapper = mount(LanguageSwitcher, { + global: { + mocks: { $t: (key: string) => key }, + stubs: { + DropdownMenuSub: container, + DropdownMenuSubTrigger: container, + DropdownMenuSubContent: container, + DropdownMenuItem: item, + }, + }, + }) + const items = wrapper.findAllComponents(item) + expect(items.map((entry) => entry.text())).toEqual(['English', '简体中文']) + expect(items[0]!.findComponent(Check).exists()).toBe(true) + expect(items[1]!.findComponent(Check).exists()).toBe(false) + items[1]!.vm.$emit('select') + await wrapper.vm.$nextTick() + expect(setLocale).toHaveBeenCalledExactlyOnceWith('zh-CN') + }) +}) diff --git a/apps/management/src/components/theme-color-contract.test.ts b/apps/management/src/components/theme-color-contract.test.ts index af3df1997..1445bc881 100644 --- a/apps/management/src/components/theme-color-contract.test.ts +++ b/apps/management/src/components/theme-color-contract.test.ts @@ -59,7 +59,7 @@ describe('management semantic color contracts', () => { const localeTypes = readSource('src/i18n/types.ts') expect(switcher).toContain('{{ localeConfig.nativeName }}') - expect(switcher).toContain('@click="setLocale(localeConfig.code)"') + expect(switcher).toContain('@select="setLocale(localeConfig.code)"') expect(switcher).toContain('isCurrentLocale(localeConfig.code)') expect(switcher).toContain('@ulticode/design-system/ui/') expect(switcher).not.toContain('localeConfig.flag') diff --git a/services/agent/data/repository_corpus_manifest.json b/services/agent/data/repository_corpus_manifest.json index bfa1be19b..04bb60f5c 100644 --- a/services/agent/data/repository_corpus_manifest.json +++ b/services/agent/data/repository_corpus_manifest.json @@ -29,8 +29,8 @@ }, { "doc_id": "repository-development", - "version": "sha256-709a16027041441823f9df0a2e4010e088f119402c7518273b73f3aad2deed2b", - "chunk_id": "repository-development:sha256-709a16027041441823f9df0a2e4010e088f119402c7518273b73f3aad2deed2b:1", + "version": "sha256-602de92a0bde04cf2bdce339f214fea142b239372b1bfc76fcabc7f82663926a", + "chunk_id": "repository-development:sha256-602de92a0bde04cf2bdce339f214fea142b239372b1bfc76fcabc7f82663926a:1", "source_path": "docs/DEVELOPMENT.md", "access_scope": "repository-public", "sample_kind": "real", From ae8862b316081051754f9367a45722335dd40c9f Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Mon, 26 Oct 2026 21:49:42 -0700 Subject: [PATCH 2/8] fix: allocate new immutable backend release versions --- scripts/test/promote-release-contract.py | 5 ++++- scripts/test/supply-chain-contract.sh | 2 +- services/admin/pom.xml | 2 +- services/app/pom.xml | 2 +- services/auth/pom.xml | 2 +- services/judge/pom.xml | 2 +- services/notification/pom.xml | 2 +- services/pom.xml | 14 +++++++------- services/search/pom.xml | 2 +- services/submission/pom.xml | 2 +- 10 files changed, 19 insertions(+), 16 deletions(-) diff --git a/scripts/test/promote-release-contract.py b/scripts/test/promote-release-contract.py index 70dcd4aac..d71a20a30 100755 --- a/scripts/test/promote-release-contract.py +++ b/scripts/test/promote-release-contract.py @@ -5,6 +5,7 @@ import os import subprocess import tempfile +import xml.etree.ElementTree as ET from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -29,7 +30,9 @@ def maven_version(service): prop = service.get("maven_version_property") if not prop: return None - return "1.0.3" + properties = ET.parse(ROOT / "services/pom.xml").getroot().find( + "{http://maven.apache.org/POM/4.0.0}properties") + return properties.findtext(f"{{http://maven.apache.org/POM/4.0.0}}{prop}") def seed(base): diff --git a/scripts/test/supply-chain-contract.sh b/scripts/test/supply-chain-contract.sh index c875a498b..f999f970b 100755 --- a/scripts/test/supply-chain-contract.sh +++ b/scripts/test/supply-chain-contract.sh @@ -177,7 +177,7 @@ for service in owner_services: owner_pom = repo / 'services' / service['module'].split('/', 1)[0] / 'pom.xml' owner_properties = ET.parse(owner_pom).getroot().find('m:properties', ns) owner_version = owner_properties.findtext(f'm:{prop}', namespaces=ns) - expected_version = '1.0.3' + expected_version = '1.0.4' assert root_version == owner_version == expected_version, f'{service["name"]}: release version mismatch ({root_version!r} != {owner_version!r})' assert 'actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333' in workflow print('digest-only candidates, common normalized repo and all-service promote gate: PASS') diff --git a/services/admin/pom.xml b/services/admin/pom.xml index 9490aade2..19822ea7c 100644 --- a/services/admin/pom.xml +++ b/services/admin/pom.xml @@ -13,7 +13,7 @@ - 1.0.3 + 1.0.4 backend-admin ${service.version.admin} diff --git a/services/app/pom.xml b/services/app/pom.xml index 23fab194c..b12530d8f 100644 --- a/services/app/pom.xml +++ b/services/app/pom.xml @@ -13,7 +13,7 @@ - 1.0.3 + 1.0.4 backend-app ${service.version.app} diff --git a/services/auth/pom.xml b/services/auth/pom.xml index 9d2c917dc..6ebeaa069 100644 --- a/services/auth/pom.xml +++ b/services/auth/pom.xml @@ -13,7 +13,7 @@ - 1.0.3 + 1.0.4 backend-auth ${service.version.auth} diff --git a/services/judge/pom.xml b/services/judge/pom.xml index 924a4cfc0..23c4ecba3 100644 --- a/services/judge/pom.xml +++ b/services/judge/pom.xml @@ -15,7 +15,7 @@ - 1.0.3 + 1.0.4 backend-judge diff --git a/services/notification/pom.xml b/services/notification/pom.xml index a717f6452..c5367664a 100644 --- a/services/notification/pom.xml +++ b/services/notification/pom.xml @@ -14,7 +14,7 @@ - 1.0.3 + 1.0.4 backend-notification ${service.version.notification} diff --git a/services/pom.xml b/services/pom.xml index dd1018b22..191b744d6 100644 --- a/services/pom.xml +++ b/services/pom.xml @@ -52,13 +52,13 @@ - 1.0.3 - 1.0.3 - 1.0.3 - 1.0.3 - 1.0.3 - 1.0.3 - 1.0.3 + 1.0.4 + 1.0.4 + 1.0.4 + 1.0.4 + 1.0.4 + 1.0.4 + 1.0.4 17 2.21.7 diff --git a/services/search/pom.xml b/services/search/pom.xml index a6c06555c..f11ebc10c 100644 --- a/services/search/pom.xml +++ b/services/search/pom.xml @@ -14,7 +14,7 @@ - 1.0.3 + 1.0.4 backend-search diff --git a/services/submission/pom.xml b/services/submission/pom.xml index 30c00ab1d..8662c30bd 100644 --- a/services/submission/pom.xml +++ b/services/submission/pom.xml @@ -15,7 +15,7 @@ - 1.0.3 + 1.0.4 backend-submission From cc9ed501791e883758b357a2be54a5a45b2db6e7 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Mon, 26 Oct 2026 22:07:01 -0700 Subject: [PATCH 3/8] fix: preserve isolation during fresh submission cutover --- docs/DEVELOPMENT.md | 63 ++++++++++++++++++- ...owner-migration-safety-integration-test.sh | 22 +++++++ scripts/runbooks/submission-schema-cutover.sh | 23 ++++++- .../data/repository_corpus_manifest.json | 4 +- 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 558d688c4..517e4fcf3 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -38,7 +38,68 @@ mise Feature 使用 `ghcr.io/devcontainers-extra/features/mise:1`,配置与锁 `host` 配置只接受 `scope` 和 `observability` 两个无密钥键;启动和 生命周期逻辑仍由 `scripts/dev/*` 与 `devstack-manifest.sh` 所有。 -### 推荐启动 +### 干净卷首次安装(仅本机 disposable 开发环境) + +首次安装与已有数据库升级是两个入口。以下流程只用于新 checkout、全新 +Compose project 和空卷;不得对已有环境执行,也不删除或重置任何卷。 +已有环境继续使用 [Operations](OPERATIONS.md#数据库迁移与-owner-收敛) 的备份、 +writer drain、增量 backfill 和 cutover 流程。先安装上面的工具链及锁定依赖; +默认端口必须空闲,独立 PM2 home 不会隔离 TCP 端口。 + +```bash +set -euo pipefail +test ! -e .env # 已有配置应走升级入口,不能 --force +export COMPOSE_PROJECT_NAME=ulticode-fresh +export PM2_HOME="$PWD/.local/fresh-pm2" +test -z "$(docker ps -aq --filter label=com.docker.compose.project=$COMPOSE_PROJECT_NAME)" +test -z "$(docker volume ls -q --filter name=${COMPOSE_PROJECT_NAME}_)" +./scripts/dev/init-env.sh +pnpm install --frozen-lockfile +./scripts/dev/up.sh --scope full-stack --prepare-submission-owner --skip-install +``` + +`--prepare-submission-owner` 只准备基础设施、canonical migrations、owner 账号和 +grant,不启动业务 writer。空卷不意味着迁移后没有数据:不可改写的历史共享迁移 +包含 sample submissions,仍必须经过 insert-only backfill 和完整 parity 检查。 +不要跳过这些检查,也不要先手工设置 `SUBMISSION_CUTOVER_COMPLETE=true`。 + +在同一个 shell 中、所有业务 writer 仍未启动时执行: + +```bash +set -a +source .env +set +a +export MIGRATION_MYSQL_CONTAINER="$(docker compose --project-directory . --env-file .env \ + -f docker/docker-compose.yml -f docker/docker-compose.dev.yml ps -q mysql)" +export MYSQL_CONTAINER="$MIGRATION_MYSQL_CONTAINER" +export MIGRATION_DB_USER=root MIGRATION_DB_PASSWORD="$MYSQL_ROOT_PASSWORD" +./scripts/runbooks/submission-schema-cutover.sh backfill +SUBMISSION_BACKFILL_CONFIRM=I_UNDERSTAND_SUBMISSION_BACKFILL \ +SUBMISSION_BACKFILL_QUIESCE_CONFIRM=I_UNDERSTAND_SUBMISSION_BACKFILL_QUIESCE_ALL_WRITERS \ + ./scripts/runbooks/submission-schema-cutover.sh backfill --execute +./scripts/runbooks/submission-schema-cutover.sh verify +SUBMISSION_CUTOVER_CONFIRM=I_UNDERSTAND_SUBMISSION_CUTOVER \ +SUBMISSION_CUTOVER_QUIESCE_CONFIRM=I_UNDERSTAND_SUBMISSION_QUIESCE_ALL_WRITERS \ + ./scripts/runbooks/submission-schema-cutover.sh cutover --execute +./scripts/runbooks/submission-schema-cutover.sh verify +export SUBMISSION_CUTOVER_COMPLETE=true +./scripts/dev/up.sh --scope full-stack --skip-infra --skip-install --rebuild +./scripts/dev/up.sh health --scope full-stack +``` + +只有最终 parity 和 writer checks 成功才导出完成标记。已隔离的 App 账号再次 cutover +仍检查 schema、parity、权限和 source 稳定性,但不重复 REVOKE,也不恢复 legacy grant。 +此处的 root migration 身份、生成的 dev-admin 和示例数据仅属于这个 disposable project, +不能复用于生产。生成的 `.env`、PM2 state、ACL、日志和数据库都不能提交。 +中断时保留卷并检查日志及正在运行的进程;不要重跑首次安装检查或删除卷来掩盖失败。 +恢复迁移可显式传入上述 `MIGRATION_MYSQL_CONTAINER`,无需安装 host mysql CLI。 + +health 通过只证明 readiness。发布前仍须用真实 Auth/App/Submission/Judge 验证浏览器 +登录、题目、提交和 terminal verdict,以及 Management/contest 权限与业务操作; +mock API、sample verdict、容器 healthy 和截图均不能替代这项验收。Agent 默认关闭, +不属于这个基础安装的模型验收。 + +### 已准备环境启动 ```bash ./scripts/dev/init-env.sh diff --git a/scripts/dev/owner-migration-safety-integration-test.sh b/scripts/dev/owner-migration-safety-integration-test.sh index 72625b0bb..8626de3fa 100755 --- a/scripts/dev/owner-migration-safety-integration-test.sh +++ b/scripts/dev/owner-migration-safety-integration-test.sh @@ -343,6 +343,28 @@ env ENV_FILE="$TEST_ENV" \ CUTOVER_COUNTS="$(mysql_root -N -B -e "SELECT (SELECT COUNT(*) FROM submission.submissions),(SELECT COUNT(*) FROM submission.judge_outbox),(SELECT COUNT(*) FROM submission.submission_result_outbox),(SELECT COUNT(*) FROM information_schema.table_privileges WHERE GRANTEE=CONCAT(CHAR(39),'app_rw',CHAR(39),'@',CHAR(39),'%',CHAR(39)) AND TABLE_SCHEMA='ulticode' AND PRIVILEGE_TYPE IN ('SELECT','INSERT','UPDATE','DELETE')); ")" [[ "$CUTOVER_COUNTS" == $'1\t1\t1\t0' ]] +# Fresh provisioning and repeated cutovers share the already-isolated state. +# Neither may restore compatibility grants as compensation for an empty REVOKE. +env ENV_FILE="$TEST_ENV" \ + SUBMISSION_CUTOVER_CONFIRM=I_UNDERSTAND_SUBMISSION_CUTOVER \ + SUBMISSION_CUTOVER_QUIESCE_CONFIRM=I_UNDERSTAND_SUBMISSION_QUIESCE_ALL_WRITERS \ + bash "$ROOT_DIR/scripts/runbooks/submission-schema-cutover.sh" cutover --execute > "$TEST_DIR/submission-cutover-repeat.log" 2>&1 +grep -q 'App grants are already absent; no grant mutation required' "$TEST_DIR/submission-cutover-repeat.log" +[[ "$(mysql_root -N -B -e "SELECT COUNT(*) FROM information_schema.table_privileges WHERE GRANTEE=CONCAT(CHAR(39),'app_rw',CHAR(39),'@',CHAR(39),'%',CHAR(39)) AND TABLE_SCHEMA='ulticode';")" == 0 ]] +printf 'already-isolated submission cutover: PASS\n' + +cp "$TEST_ENV" "$TEST_DIR/missing-app-account.env" +printf '\nSUBMISSION_APP_DB_HOST=missing-host\n' >> "$TEST_DIR/missing-app-account.env" +if env ENV_FILE="$TEST_DIR/missing-app-account.env" \ + SUBMISSION_CUTOVER_CONFIRM=I_UNDERSTAND_SUBMISSION_CUTOVER \ + SUBMISSION_CUTOVER_QUIESCE_CONFIRM=I_UNDERSTAND_SUBMISSION_QUIESCE_ALL_WRITERS \ + bash "$ROOT_DIR/scripts/runbooks/submission-schema-cutover.sh" cutover --execute > "$TEST_DIR/submission-missing-account.log" 2>&1; then + echo 'Already-isolated cutover accepted a missing runtime account.' >&2 + exit 1 +fi +grep -q 'writer differences are not zero' "$TEST_DIR/submission-missing-account.log" +printf 'already-isolated missing account rejection: PASS\n' + env ENV_FILE="$TEST_ENV" \ SUBMISSION_CUTOVER_CONFIRM=I_UNDERSTAND_SUBMISSION_ROLLBACK \ SUBMISSION_CUTOVER_QUIESCE_CONFIRM=I_UNDERSTAND_SUBMISSION_QUIESCE_ALL_WRITERS \ diff --git a/scripts/runbooks/submission-schema-cutover.sh b/scripts/runbooks/submission-schema-cutover.sh index ea75add5f..f5a3769db 100755 --- a/scripts/runbooks/submission-schema-cutover.sh +++ b/scripts/runbooks/submission-schema-cutover.sh @@ -412,8 +412,12 @@ do_backfill() { } app_grants_absent() { - local table_privilege_count column_privilege_count check_status + local table_privilege_count column_privilege_count check_status account_count [[ -n "$APP_DB_USER" && -n "$APP_DB_HOST" ]] || return 1 + account_count="$(mysql_query "SELECT COUNT(*) FROM mysql.user WHERE User = '$APP_DB_USER';")" || return 1 + [[ "$account_count" == "1" ]] || return 1 + account_count="$(mysql_query "SELECT COUNT(*) FROM mysql.user WHERE User = '$APP_DB_USER' AND Host = '$APP_DB_HOST';")" || return 1 + [[ "$account_count" == "1" ]] || return 1 table_privilege_count="$(mysql_query "SELECT COUNT(*) FROM information_schema.table_privileges WHERE GRANTEE = CONCAT(CHAR(39), '$APP_DB_USER', CHAR(39), '@', CHAR(39), '$APP_DB_HOST', CHAR(39)) AND TABLE_SCHEMA = '$SOURCE_SCHEMA' @@ -829,6 +833,23 @@ case "$ACTION" in echo "Unable to capture source rows/checksums before grant revocation; refusing cutover." >&2 exit 1 fi + # Fresh owner provisioning may already have removed the compatibility + # grants. A repeated REVOKE fails on MySQL; its compensation must never + # reintroduce a writer into this verified, already-isolated state. + if app_grants_absent; then + source_after="$(source_snapshot "$SOURCE_SCHEMA")" || { + echo "Unable to recheck already-isolated source; refusing cutover." >&2 + exit 1 + } + [[ "$source_before" == "$source_after" ]] || { + echo "Source changed during already-isolated cutover verification." >&2 + exit 1 + } + print_snapshot "$SOURCE_SCHEMA" source-after-cutover + print_snapshot "$TARGET_SCHEMA" target-after-cutover + echo "CUTOVER PASS: App grants are already absent; no grant mutation required." + exit 0 + fi if ! revoke_app_grants; then echo "Grant revocation failed; restoring App grants before stopping." >&2 restore_app_grants || echo "CRITICAL: App grant restoration failed; stop all writers and repair grants manually." >&2 diff --git a/services/agent/data/repository_corpus_manifest.json b/services/agent/data/repository_corpus_manifest.json index 04bb60f5c..f1e152c50 100644 --- a/services/agent/data/repository_corpus_manifest.json +++ b/services/agent/data/repository_corpus_manifest.json @@ -29,8 +29,8 @@ }, { "doc_id": "repository-development", - "version": "sha256-602de92a0bde04cf2bdce339f214fea142b239372b1bfc76fcabc7f82663926a", - "chunk_id": "repository-development:sha256-602de92a0bde04cf2bdce339f214fea142b239372b1bfc76fcabc7f82663926a:1", + "version": "sha256-ac417e4707bba3bde1cd0d27afc27f4b30bc00840b4a7b310c8f3751df8aebfb", + "chunk_id": "repository-development:sha256-ac417e4707bba3bde1cd0d27afc27f4b30bc00840b4a7b310c8f3751df8aebfb:1", "source_path": "docs/DEVELOPMENT.md", "access_scope": "repository-public", "sample_kind": "real", From bf88a6c87398d9d8cea95957ad33fd77f4aeb6bf Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Mon, 26 Oct 2026 22:44:41 -0700 Subject: [PATCH 4/8] fix: redact and consume websocket session credentials --- docs/ARCHITECTURE.md | 2 +- .../data/repository_corpus_manifest.json | 4 +- .../interceptor/JwtChannelInterceptor.java | 12 +++--- .../WebSocketAuthHandshakeInterceptor.java | 11 ++++- .../JwtChannelInterceptorTest.java | 18 ++++++-- ...WebSocketAuthHandshakeInterceptorTest.java | 42 +++++++++++++++++++ 6 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 services/app/app-web/src/test/java/com/ulticode/modules/websocket/interceptor/WebSocketAuthHandshakeInterceptorTest.java diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d1ac73c79..351e31890 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -93,7 +93,7 @@ flowchart LR 2. **单写者**:Submission 和 Notification 的持久化 writer 分别只在对应 Owner;App 只保留 Submission remote adapter、Notification intent 发布和 WebSocket relay。 3. **单跳同步调用**:Admin 可调用 Auth/App/Notification 的一个 Provider;Provider 不为同一命令同步调用第三个 Provider。跨边界副作用使用 Outbox/Inbox/Streams。 4. **本地事务**:强一致 invariant 留在一个 Owner 的 MySQL 事务内;Redis、SMTP、WebSocket、对象存储、搜索和审计使用 outbox、inbox、lease、fence 或补偿。 -5. **安全边界**:共享 `platform/web-security` 负责 JWT/JWKS、Cookie CSRF、委托断言和 replay guard;`platform/observability` 在 OTLP authorization 配置时 fail closed,拒绝非 HTTPS endpoint;各 Owner 只保留路由与策略包装。访问令牌只来自 HttpOnly cookie;WebSocket 只接受 `access_token` cookie。 +5. **安全边界**:共享 `platform/web-security` 负责 JWT/JWKS、Cookie CSRF、委托断言和 replay guard;`platform/observability` 在 OTLP authorization 配置时 fail closed,拒绝非 HTTPS endpoint;各 Owner 只保留路由与策略包装。访问令牌只来自 HttpOnly cookie;WebSocket 只接受 `access_token` cookie。Handshake 暂存凭据使用日志可安全呈现的 opaque wrapper;CONNECT 消费后移除凭据,session/debug 日志不输出原始 token。 ### 查找入口 diff --git a/services/agent/data/repository_corpus_manifest.json b/services/agent/data/repository_corpus_manifest.json index f1e152c50..bb7182e00 100644 --- a/services/agent/data/repository_corpus_manifest.json +++ b/services/agent/data/repository_corpus_manifest.json @@ -15,8 +15,8 @@ }, { "doc_id": "repository-architecture", - "version": "sha256-9ae8657d7a83a51d8a5850b3bb724716d9d3ad63af94f8f062fa45ad11a48d90", - "chunk_id": "repository-architecture:sha256-9ae8657d7a83a51d8a5850b3bb724716d9d3ad63af94f8f062fa45ad11a48d90:1", + "version": "sha256-cd35f7993d889c5c1d9b8ed488e7f10d9aded3ed56cc024ba7f809c922d69dae", + "chunk_id": "repository-architecture:sha256-cd35f7993d889c5c1d9b8ed488e7f10d9aded3ed56cc024ba7f809c922d69dae:1", "source_path": "docs/ARCHITECTURE.md", "access_scope": "repository-public", "sample_kind": "real", diff --git a/services/app/app-web/src/main/java/com/ulticode/modules/websocket/interceptor/JwtChannelInterceptor.java b/services/app/app-web/src/main/java/com/ulticode/modules/websocket/interceptor/JwtChannelInterceptor.java index 90dfc0b5e..081dce180 100644 --- a/services/app/app-web/src/main/java/com/ulticode/modules/websocket/interceptor/JwtChannelInterceptor.java +++ b/services/app/app-web/src/main/java/com/ulticode/modules/websocket/interceptor/JwtChannelInterceptor.java @@ -78,12 +78,10 @@ public Message preSend(Message message, MessageChannel channel) { private Message handleConnect(StompHeaderAccessor accessor, Message message, MessageChannel channel) { log.debug("=== Handling CONNECT for sessionId: {} ===", accessor.getSessionId()); - log.debug("Session attributes BEFORE auth: {}", accessor.getSessionAttributes()); try { authenticateConnection(accessor); log.debug("=== CONNECT authenticated successfully for sessionId: {} ===", accessor.getSessionId()); - log.debug("Session attributes AFTER auth: {}", accessor.getSessionAttributes()); log.debug("User principal AFTER auth: {}", accessor.getUser()); } catch (WebSocketAuthenticationException e) { log.error("=== CONNECT auth FAILED for sessionId: {}: {} ===", @@ -136,8 +134,7 @@ private void validateUserSession(StompHeaderAccessor accessor, StompCommand comm } private void authenticateConnection(StompHeaderAccessor accessor) { - log.debug("Authenticating WebSocket CONNECT, sessionId: {}, sessionAttributes: {}", - accessor.getSessionId(), accessor.getSessionAttributes()); + log.debug("Authenticating WebSocket CONNECT, sessionId: {}", accessor.getSessionId()); // The handshake interceptor is the only token source; client-controlled // STOMP headers must never authenticate a connection. @@ -165,9 +162,10 @@ private void authenticateConnection(StompHeaderAccessor accessor) { private Optional extractTokenFromSession(StompHeaderAccessor accessor) { Map sessionAttributes = accessor.getSessionAttributes(); if (sessionAttributes != null) { - Object auth = sessionAttributes.get("auth"); - if (auth instanceof String token && !token.isEmpty()) { - return Optional.of(token); + Object auth = sessionAttributes.remove("auth"); + if (auth instanceof WebSocketAuthHandshakeInterceptor.SessionCredential credential + && credential.value() != null && !credential.value().isEmpty()) { + return Optional.of(credential.value()); } } return Optional.empty(); diff --git a/services/app/app-web/src/main/java/com/ulticode/modules/websocket/interceptor/WebSocketAuthHandshakeInterceptor.java b/services/app/app-web/src/main/java/com/ulticode/modules/websocket/interceptor/WebSocketAuthHandshakeInterceptor.java index c238a6888..37998a831 100644 --- a/services/app/app-web/src/main/java/com/ulticode/modules/websocket/interceptor/WebSocketAuthHandshakeInterceptor.java +++ b/services/app/app-web/src/main/java/com/ulticode/modules/websocket/interceptor/WebSocketAuthHandshakeInterceptor.java @@ -26,6 +26,15 @@ public class WebSocketAuthHandshakeInterceptor implements HandshakeInterceptor { private static final Logger log = LoggerFactory.getLogger(WebSocketAuthHandshakeInterceptor.class); + // Framework error messages may stringify session attributes before CONNECT. + // Keep the credential opaque until its single authentication use. + record SessionCredential(String value) { + @Override + public String toString() { + return "[REDACTED]"; + } + } + private final TokenExtractor tokenExtractor; public WebSocketAuthHandshakeInterceptor(TokenExtractor tokenExtractor) { @@ -44,7 +53,7 @@ public boolean beforeHandshake( var tokenOpt = tokenExtractor.extractToken(request); if (tokenOpt.isPresent()) { - attributes.put("auth", tokenOpt.get()); + attributes.put("auth", new SessionCredential(tokenOpt.get())); } else { log.warn("WebSocket handshake rejected: no authentication token found"); return false; diff --git a/services/app/app-web/src/test/java/com/ulticode/modules/websocket/interceptor/JwtChannelInterceptorTest.java b/services/app/app-web/src/test/java/com/ulticode/modules/websocket/interceptor/JwtChannelInterceptorTest.java index cf4737641..fbb0f9a3e 100644 --- a/services/app/app-web/src/test/java/com/ulticode/modules/websocket/interceptor/JwtChannelInterceptorTest.java +++ b/services/app/app-web/src/test/java/com/ulticode/modules/websocket/interceptor/JwtChannelInterceptorTest.java @@ -75,6 +75,14 @@ void preSend_withNonConnectCommand_returnsMessage() { verifyNoInteractions(authenticator); } + @Test + void handshakeCredential_isRedactedBeforeConnect() { + Map attributes = new HashMap<>(); + attributes.put("auth", new WebSocketAuthHandshakeInterceptor.SessionCredential("private-cookie-token")); + assertFalse(attributes.toString().contains("private-cookie-token")); + assertTrue(attributes.toString().contains("[REDACTED]")); + } + @Test void preSend_withValidToken_delegatesAndAttachesPrincipal() { String token = "valid-token"; @@ -86,7 +94,7 @@ void preSend_withValidToken_delegatesAndAttachesPrincipal() { Map sessionAttrs = new HashMap<>(); accessor.setSessionAttributes(sessionAttrs); - sessionAttrs.put("auth", token); + sessionAttrs.put("auth", new WebSocketAuthHandshakeInterceptor.SessionCredential(token)); when(authenticator.authenticate(Optional.of(token))).thenReturn(clientData); Message message = MessageBuilder.createMessage("", accessor.getMessageHeaders()); @@ -94,6 +102,8 @@ void preSend_withValidToken_delegatesAndAttachesPrincipal() { assertSame(message, result); verify(authenticator).authenticate(Optional.of(token)); + assertFalse(accessor.getSessionAttributes().containsKey("auth")); + assertFalse(message.toString().contains(token)); assertEquals(clientData, accessor.getSessionAttributes().get("user")); assertNotNull(accessor.getUser()); } @@ -106,7 +116,7 @@ void preSend_propagatesAuthenticatorException() { Map sessionAttrs = new HashMap<>(); accessor.setSessionAttributes(sessionAttrs); - sessionAttrs.put("auth", token); + sessionAttrs.put("auth", new WebSocketAuthHandshakeInterceptor.SessionCredential(token)); when(authenticator.authenticate(any())) .thenThrow(new WebSocketAuthenticationException( WebSocketErrorCode.TOKEN_BLACKLISTED, "Token has been revoked")); @@ -117,6 +127,8 @@ void preSend_propagatesAuthenticatorException() { WebSocketAuthenticationException.class, () -> interceptor.preSend(message, channel)); assertEquals(WebSocketErrorCode.TOKEN_BLACKLISTED, ex.getErrorCode()); + assertFalse(accessor.getSessionAttributes().containsKey("auth")); + assertFalse(message.toString().contains(token)); } @Test @@ -171,7 +183,7 @@ void preSend_usesSessionTokenFromHandshakeCookie() { StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.CONNECT); accessor.setLeaveMutable(true); Map sessionAttrs = new HashMap<>(); - sessionAttrs.put("auth", sessionToken); + sessionAttrs.put("auth", new WebSocketAuthHandshakeInterceptor.SessionCredential(sessionToken)); accessor.setSessionAttributes(sessionAttrs); when(authenticator.authenticate(Optional.of(sessionToken))) diff --git a/services/app/app-web/src/test/java/com/ulticode/modules/websocket/interceptor/WebSocketAuthHandshakeInterceptorTest.java b/services/app/app-web/src/test/java/com/ulticode/modules/websocket/interceptor/WebSocketAuthHandshakeInterceptorTest.java new file mode 100644 index 000000000..f9771b714 --- /dev/null +++ b/services/app/app-web/src/test/java/com/ulticode/modules/websocket/interceptor/WebSocketAuthHandshakeInterceptorTest.java @@ -0,0 +1,42 @@ +package com.ulticode.modules.websocket.interceptor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import com.ulticode.modules.websocket.util.TokenExtractor; +import java.net.URI; +import java.util.HashMap; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; + +class WebSocketAuthHandshakeInterceptorTest { + @Test + void storesOnlyAnOpaqueCookieCredential() { + TokenExtractor extractor = mock(TokenExtractor.class); + ServerHttpRequest request = mock(ServerHttpRequest.class); + when(request.getURI()).thenReturn(URI.create("http://localhost/ws")); + when(extractor.extractToken(request)).thenReturn(Optional.of("private-cookie-token")); + var attributes = new HashMap(); + + assertTrue(new WebSocketAuthHandshakeInterceptor(extractor).beforeHandshake( + request, mock(ServerHttpResponse.class), mock(WebSocketHandler.class), attributes)); + assertInstanceOf(WebSocketAuthHandshakeInterceptor.SessionCredential.class, attributes.get("auth")); + assertFalse(attributes.toString().contains("private-cookie-token")); + assertTrue(attributes.toString().contains("[REDACTED]")); + } + + @Test + void missingCookieStillRejectsHandshake() { + TokenExtractor extractor = mock(TokenExtractor.class); + ServerHttpRequest request = mock(ServerHttpRequest.class); + when(request.getURI()).thenReturn(URI.create("http://localhost/ws")); + when(extractor.extractToken(request)).thenReturn(Optional.empty()); + var attributes = new HashMap(); + assertFalse(new WebSocketAuthHandshakeInterceptor(extractor).beforeHandshake( + request, mock(ServerHttpResponse.class), mock(WebSocketHandler.class), attributes)); + assertTrue(attributes.isEmpty()); + } +} From b36428757c327234cd9349bc68d12991f9f8b226 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Mon, 26 Oct 2026 22:54:46 -0700 Subject: [PATCH 5/8] feat: add reproducible release acceptance and preparation --- .gitignore | 4 + CHANGELOG.md | 30 ++++ CODE_OF_CONDUCT.md | 17 ++ CONTRIBUTING.md | 27 ++++ SECURITY.md | 24 +++ .../src/stores/admin/system-settings.ts | 2 +- docs/DEVELOPMENT.md | 24 ++- docs/OPERATIONS.md | 11 ++ docs/PRODUCT.md | 2 +- scripts/runbooks/prepare-release-bundle.py | 64 ++++++++ scripts/test/browser-acceptance.mjs | 146 ++++++++++++++++++ .../admin/dto/settings/AllSettingsVO.java | 2 +- .../admin/dto/settings/FeatureTogglesVO.java | 2 +- .../impl/SystemSettingsServiceImpl.java | 2 +- .../impl/SystemSettingsServiceImplTest.java | 18 ++- .../data/repository_corpus_manifest.json | 16 +- 16 files changed, 369 insertions(+), 22 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 scripts/runbooks/prepare-release-bundle.py create mode 100644 scripts/test/browser-acceptance.mjs diff --git a/.gitignore b/.gitignore index 72c398928..e3b1d3abc 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ !/AGENTS.md !/CONTEXT.md !/LICENSE +!/SECURITY.md +!/CONTRIBUTING.md +!/CODE_OF_CONDUCT.md +!/CHANGELOG.md !/README.md !/README.zh-CN.md !/package.json diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..25bdfb182 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +## Unreleased + +This is release-candidate preparation, not an official stable release or a +production-readiness declaration. + +- Restore Management's language-menu CI contract and test actual selection-event + handling and the current-language indicator. +- Refresh Agent repository-document version binding while preserving license, + content, permission and source-integrity rejection gates. +- Allocate backend service version `1.0.4` in reactor and owner POMs; preserve + immutable image tags and require the complete nine-service release manifest. +- Document a separate disposable fresh-volume installation procedure. Keep + canonical migrations and sample data, insert-only backfill, parity, writer + isolation, backups and legacy upgrade/rollback gates intact. +- Make already-isolated Submission cutover a verified no-op rather than + restoring legacy grants after an empty REVOKE. +- Redact the transient WebSocket handshake credential and consume it at CONNECT; + authentication remains cookie-only and fail-closed. + +The Agent remains experimental and disabled by default. Tests using scripted +models do not establish real-model quality, budget readiness or production +acceptance. Local Docker/browser evidence is scoped to disposable local services. + +Before an official release, retain the tested source commit, successful CI and +real-service acceptance evidence, full signed nine-service digest manifest, +SBOM/provenance/scan evidence and release notes as durable assets. The source-only +bundle is not deployment input. Production deployment, release publication and +branch-protection changes require separate approval. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..3f65965ca --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,17 @@ +# Community expectations + +Be respectful, specific and constructive in issues, reviews and discussions. +Critique the work rather than the person. Harassment, threats, discrimination, +sexualized conduct, doxxing and disclosure of another person's private information +are unacceptable. Respect maintainers' decisions about scope and moderation. + +These expectations apply in project-managed spaces. Maintainers may edit or +remove harmful content, close discussions, or restrict participation. Moderation +should be proportionate to the behavior and protect the people involved. + +The maintainer is [DavidHLP](https://github.com/DavidHLP). Use contact details the +maintainer publishes on that profile for private conduct concerns. If no private +contact is listed, request a private contact channel in a minimal repository issue +without naming people or publishing evidence. This document does not invent a +private email address or promise a response deadline. Security vulnerabilities +have a separate verified reporting channel in [SECURITY.md](SECURITY.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..3782e19d0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +Use [issues](https://github.com/DavidHLP/UltiCode/issues) for reproducible bugs and +scoped proposals. Include the revision, expected and observed behavior, and a +minimal reproduction with synthetic data. Follow [SECURITY.md](SECURITY.md) for +vulnerabilities; never attach secrets or real user submissions to public reports. + +Read [AGENTS.md](AGENTS.md), the nearest component guide, and the +[documentation map](docs/index.md) before changing code. Start from a branch, +preserve unrelated changes, and keep pull requests focused. Explain the concrete +behavior change and list checks as passed, failed, skipped or not run. Link the +issue when one exists; an issue is not required for a small repair. + +Use the supported local setup and relevant checks in +[Development](docs/DEVELOPMENT.md). Browser acceptance requires disposable real +services; mocks and existing sample verdicts are not end-to-end evidence. Do not +run tests against production data or make paid model calls without permission. + +Keep applied migrations immutable; add a forward migration when needed. Preserve +authentication, owner isolation, sandbox limits, backup/cutover and artifact +verification gates. Release tags are immutable: changed image digests need new +service versions. Publishing, deployment and merging are separate maintainer +actions, not consequences of a passing test or draft pull request. + +Contributions are provided under the repository's [MIT license](LICENSE). Keep +third-party attribution and submit only material you have permission to share. +Be respectful and follow the [community expectations](CODE_OF_CONDUCT.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..6d3be2f24 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security policy + +UltiCode is under active development. This repository has no published stable +GitHub Release or production security certification. Reports against the current +`main` branch are welcome; include the exact commit or immutable image digest. +The Agent is experimental and disabled by default. + +## Private reporting + +Use [Security → Report a vulnerability](https://github.com/DavidHLP/UltiCode/security). +GitHub private vulnerability reporting is enabled for this repository. Do not +publish exploit details, credentials, personal data or production submissions in +an issue, pull request or discussion. No dedicated security email or response-time +SLA is advertised here. + +Include the affected component and revision, impact, prerequisites, and a minimal +reproduction using synthetic data in a disposable environment. Describe any +mitigation already applied. Coordinate disclosure with the maintainer before +making the report public. Do not access other users' data or test against a +third-party deployment without its operator's permission. + +The project maintainer is [DavidHLP](https://github.com/DavidHLP). Operational +hardening, backup, migration and sandbox boundaries are documented in +[Operations](docs/OPERATIONS.md) and [AGENTS.md](AGENTS.md). diff --git a/apps/management/src/stores/admin/system-settings.ts b/apps/management/src/stores/admin/system-settings.ts index 386d4cbe9..e14586bfe 100644 --- a/apps/management/src/stores/admin/system-settings.ts +++ b/apps/management/src/stores/admin/system-settings.ts @@ -130,7 +130,7 @@ function defaultAllSettings(): AllSettings { feature_notifications: true, feature_bookmarks: true, feature_problem_lists: true, - feature_agent: true, + feature_agent: false, } } diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 517e4fcf3..91f5bb2ad 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -99,6 +99,26 @@ health 通过只证明 readiness。发布前仍须用真实 Auth/App/Submission/ mock API、sample verdict、容器 healthy 和截图均不能替代这项验收。Agent 默认关闭, 不属于这个基础安装的模型验收。 +### 真实服务浏览器验收 + +`scripts/test/browser-acceptance.mjs` 只允许 loopback origins,并要求 +`ACCEPTANCE_DISPOSABLE=true`。准备上述 disposable stack、`two-sum` 示例、可用 +Python Judge sandbox 和已授权的测试账号后,通过安全环境提供 +`ACCEPTANCE_USERNAME` / `ACCEPTANCE_PASSWORD`。Management 可独立提供 +`ACCEPTANCE_MANAGEMENT_USERNAME` / `ACCEPTANCE_MANAGEMENT_PASSWORD`;账号必须具有 +READ:PROBLEM、READ:CONTEST、CREATE:CONTEST 及评分规则读取所需权限。缺少权限应失败, +不得修改全局角色策略或在测试中伪造授权。只在 disposable fixture 上显式准备所需权限。 + +```bash +ACCEPTANCE_DISPOSABLE=true node scripts/test/browser-acceptance.mjs +``` + +默认使用 Playwright 的 Chromium;可通过 `ACCEPTANCE_CHROMIUM` 选择已安装的浏览器。 +脚本使用真实登录、Monaco model、提交按钮、Submission readback、Judge terminal +Accepted、Management 数据读取与未发布 contest 的创建/readback,不拦截 API。 +报告在 `.local/browser-acceptance/report.json`,不保存 cookies、凭据或源码。提交与 +未发布 contest fixture 保留用于诊断,不自动删除数据。此检查不是生产、性能或模型验收。 + ### 已准备环境启动 ```bash @@ -170,7 +190,7 @@ During a retry, an old run's terminal event cannot release the pending run reque Console 的提交场景入口是共享提交详情中的可收起复盘面板,用于 `/submissions/:id` 和题目页选中的提交。“我的提交”直接进入对应结果;旧 `/personal/agent?threadId=...` 只负责验证线程归属并跳转,无线程时返回个人提交列表。 -Agent 对话默认开启,不再读取 `VITE_AGENT_REVIEW_ENABLED`。管理员通过 Management“系统设置 → 功能开关 → Agent 智能体”统一控制,沿用该页的保存流程和管理权限。设置持久化在 Admin 的既有 features 配置中,旧配置缺少字段时默认开启。用户端不提供开关。Agent 每次请求从 Admin 的 `/api/v1/public/features/agent` 读取可用状态;关闭后拒绝新的对话、分析及保存操作,保留已有数据和正在执行的分析。配置读取失败返回 503,不继续调用模型。独立部署可通过 `ULTICODE_ADMIN_BASE` 配置 Admin 地址,默认 `http://127.0.0.1:9102`。此设置不替代模型及持久化授权。仅本人、已知终态、明确 `contestSubmission: false` 的普通练习提交可用;旧 Java provider 缺少字段时不开放。Agent 服务再次核对来源资格。 +Agent 对话是实验功能,默认关闭,不再读取 `VITE_AGENT_REVIEW_ENABLED`。管理员通过 Management“系统设置 → 功能开关 → Agent 智能体”统一控制,沿用该页的保存流程和管理权限。设置持久化在 Admin 的既有 features 配置中,旧配置缺少字段时默认关闭。用户端不提供开关。Agent 每次请求从 Admin 的 `/api/v1/public/features/agent` 读取可用状态;关闭后拒绝新的对话、分析及保存操作,保留已有数据和正在执行的分析。配置读取失败返回 503,不继续调用模型。独立部署可通过 `ULTICODE_ADMIN_BASE` 配置 Admin 地址,默认 `http://127.0.0.1:9102`。此设置不替代模型及持久化授权。仅本人、已知终态、明确 `contestSubmission: false` 的普通练习提交可用;旧 Java provider 缺少字段时不开放。Agent 服务再次核对来源资格。 打开面板后,认证的 `GET /agent/capabilities` 返回分析、持久化和来源模式。查询失败不影响原提交查看;打开、收起和恢复线程均不自动分析。`metadata` 模式使用提交元数据与授权参考资料;`owner_source` 模式要求用户明确同意源码外发,只读取当前线程绑定的本人源码,限制为 12,000 字符,不持久化源码正文或引用摘录。 授权源码的会话由后端先调用 `get_submission_source`,将返回内容作为当前轮的不可信证据传给模型,并记录实际读取轨迹,再依据返回证据回答。源码专用预算日志中的连接失败仅可在官方用量统计与既有回执完整吻合后,按零额外计费核账;保留失败原因、原始备份、证据摘要和调用保留额度,恢复日志区分核账凭据与模型响应回执。 @@ -279,7 +299,7 @@ The published Zod 3 adapter is not used; production dependency audits retain the - 中文/英文文案同步,保留键盘操作、焦点管理和窄屏可用性。UI 行为变化需要对应回归测试, 关键用户旅程需要单独的浏览器验证,不能用构建成功代替。 - 保留 Agent 工作区、提交复盘与学习计划的既有资格、授权及降级行为; - Agent 对话默认开启,仅管理员通过系统设置中的功能开关关闭;它不代表分析或保存授权。 + Agent 对话是实验功能,默认关闭,仅管理员通过系统设置中的功能开关显式启用;它不代表分析或保存授权。 复现前端基线时运行只读门禁,避免 `lint --fix` 或全树格式化改变对照源码: diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index dd67cddb8..24ed456bb 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -24,6 +24,17 @@ Each service must pass HIGH/CRITICAL Trivy scanning, then be signed and receive Use the `release-manifest` artifact from a successful CI run's Docker Publish stage as canonical deployment input. It contains complete nine-service `release-manifest.txt`, `release-set.json`, and per-service evidence. Docker Publish is a reusable workflow called only after the same commit passes `ci-ok`; its authorization still requires a `main` push, never a PR, merge-group or manual verification run. Local `scripts/runbooks/promote-release.sh EVIDENCE_DIR SOURCE_SHA ghcr.io/davidhlp/ulticode` supports read-only dry-run only; `--execute` remains restricted to the authorized publisher on `main`. The workflow uses repository-wide `ulticode-release-tag-writer` concurrency with cancellation disabled. If promotion fails after candidate builds succeed, rerun only the failed `promote` job in that same Actions run, while its nine candidate artifacts remain within their retention period; do not rerun successful `build-and-push` jobs, since a rebuild may produce a different digest and collide with immutable tags. There is no independent push trigger or `workflow_dispatch` publishing entry point. Do not promote tags through local commands or other writers. This serializes repository-controlled writers, not external registry actors, and GHCR offers no compare-and-swap guarantee here. Existing manifest conflicts fail before registry inspection/writes; all planned tags are read back after promotion, including tags that already existed, before complete manifests are written. Matching existing tags are idempotent; a different digest is a hard failure. A mid-promotion failure may leave already verified tags in place, but emits no complete manifest. Never retarget or delete old tags/digests; retain previous successful release artifact for rollback. +### 未发布的源码制品准备 + +`python3 scripts/runbooks/prepare-release-bundle.py FULL_COMMIT_SHA NEW_OUTPUT_DIR` +生成固定 commit 的源码 tarball、release notes、MIT notice、SHA256SUMS 和九服务 +release plan。工具拒绝覆盖已有目录,不创建 tag、镜像、GitHub Release 或部署。 +plan 的 image_ref 保持 null,并标记 source-only-unpublished;它不是完整 digest manifest, +不能传给部署入口。批准发布后,仍须由受控 publisher 生成全部九服务的签名、SBOM、 +provenance、扫描和 immutable digest manifest,再与源码及 release notes 一起保留。 +GitHub Actions 的限期 artifact 不能单独充当长期发行存储;正式发布前应批准长期资产 +存放位置及保留策略,不得把本地 source bundle 宣称为已验证运行镜像。 + After validating the manifest locally, provide its nine lines to the manual `cd-deploy.yml` or `cd-rollback.yml` `image_refs` input. Do not hand-assemble a partial candidate list. Deployment still independently verifies immutable digest references and supply-chain evidence before mutating the host. The exact certificate identity remains `https://github.com///.github/workflows/docker-publish.yml@refs/heads/main`: Fulcio derives the GitHub certificate SAN from `job_workflow_ref`, including reusable workflows. This is distinct from `GITHUB_WORKFLOW_REF`, which identifies the calling `ci.yml` and is used by the promotion execution guard. Both new and retained releases use the same signer identity; no additional legacy identity or wildcard fallback is needed. Signature, SPDX and SLSA verification must all require that exact identity and issuer. Trivy JSON reports are uploaded per candidate even when scanning fails; a failed or skipped scan cannot produce candidate evidence or reach promotion. For vulnerability triage, inspect affected package, installed version, and fixed version. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 46d6012dd..192c2981e 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -43,7 +43,7 @@ Admin 负责管理、审核、审计、设置、监控、备份编排和运营 ### 提交复盘与学习计划 -Agent 提供独立的多轮分析工作区 `/agent`,首版仅支持本人普通练习提交。用户从提交详情进入后,会话固定绑定该提交;侧栏可恢复历史会话,后续问题继续使用同一对象与有界的前文上下文。`/personal/agent` 保留为旧学习计划线程链接的迁移入口,不替代新工作区。提交详情仍保留单次复盘与可选学习计划流程;新建对话不会复用或更改该工作流状态。工作区由 `VITE_AGENT_REVIEW_ENABLED=true` 显式启用,默认关闭;关闭或 Agent 不可用不改变题目、判题、源码查看与原题解流程。 +Agent 提供独立的多轮分析工作区 `/agent`,首版仅支持本人普通练习提交。用户从提交详情进入后,会话固定绑定该提交;侧栏可恢复历史会话,后续问题继续使用同一对象与有界的前文上下文。`/personal/agent` 保留为旧学习计划线程链接的迁移入口,不替代新工作区。提交详情仍保留单次复盘与可选学习计划流程;新建对话不会复用或更改该工作流状态。工作区是实验功能,由管理员通过 Management 的 Agent 功能设置显式启用,缺少设置时默认关闭;不再读取旧 `VITE_AGENT_REVIEW_ENABLED` 标志;关闭或 Agent 不可用不改变题目、判题、源码查看与原题解流程。 这套整合沿用 [Notion 产品定义与交互契约](https://app.notion.com/p/3f374b09d6af81dca56fda4e4c1aaa35)、[DAV-54](https://linear.app/davidhlp/issue/DAV-54)、[DAV-56](https://linear.app/davidhlp/issue/DAV-56) 的“本人普通练习提交详情”主入口决定,复用 [GitHub #287](https://github.com/DavidHLP/UltiCode/pull/287) 的工作流交互和 [#288](https://github.com/DavidHLP/UltiCode/pull/288) 的受控本地分析边界。外部记录中的设计完成、历史测试及旧执行队列退役不代表当前版本已完成真实用户试点或正式模型/Java 验收;运行事实仍以当前代码、授权和当次验证为准。 diff --git a/scripts/runbooks/prepare-release-bundle.py b/scripts/runbooks/prepare-release-bundle.py new file mode 100644 index 000000000..7c8e25573 --- /dev/null +++ b/scripts/runbooks/prepare-release-bundle.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Prepare an unpublished source bundle; never create tags, images or a Release.""" +import argparse +import hashlib +import json +from pathlib import Path +import re +import subprocess +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[2] + + +def git(*args): + return subprocess.check_output(['git', '-C', str(ROOT), *args]) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('source_commit', help='Full immutable Git commit SHA') + parser.add_argument('output', type=Path, help='New output directory; existing paths are refused') + args = parser.parse_args() + sha = args.source_commit + if not re.fullmatch(r'[0-9a-f]{40}', sha): + parser.error('source_commit must be a full lowercase SHA') + if git('rev-parse', '--verify', f'{sha}^{{commit}}').decode().strip() != sha: + parser.error('source_commit must identify the exact commit object') + matrix = json.loads(git('show', f'{sha}:.github/services-matrix.json')) + if len(matrix) != 9 or len({entry['name'] for entry in matrix}) != 9: + parser.error('source must declare exactly nine unique deployable services') + ns = '{http://maven.apache.org/POM/4.0.0}' + properties = ET.fromstring(git('show', f'{sha}:services/pom.xml')).find(f'{ns}properties') + services = [] + for entry in matrix: + prop = entry.get('maven_version_property') + version = properties.findtext(f'{ns}{prop}') if prop else None + if prop and not version: + parser.error(f'missing version for {entry["name"]}') + services.append({'name': entry['name'], 'planned_version': version, 'image_ref': None}) + notes = git('show', f'{sha}:CHANGELOG.md') + license_text = git('show', f'{sha}:LICENSE') + # No replacement or cleanup: an interrupted directory is preserved for review. + args.output.mkdir(parents=True, exist_ok=False) + archive = args.output / f'UltiCode-{sha}.tar.gz' + subprocess.run(['git', '-C', str(ROOT), 'archive', '--format=tar.gz', + f'--prefix=UltiCode-{sha}/', '-o', str(archive.resolve()), sha], check=True) + (args.output / 'release-notes.md').write_bytes(notes) + (args.output / 'LICENSE').write_bytes(license_text) + plan = {'status': 'source-only-unpublished', 'source_commit': sha, + 'image_manifest_available': False, 'services': services, + 'remaining_gate': 'Approved publisher must produce and verify all nine immutable image references; this plan is not deployment input.'} + (args.output / 'release-plan.json').write_text(json.dumps(plan, indent=2, sort_keys=True) + '\n') + paths = sorted(args.output.iterdir()) + checksums = [] + for path in paths: + with path.open('rb') as stream: + digest = hashlib.file_digest(stream, 'sha256').hexdigest() + checksums.append(f'{digest} {path.name}\n') + (args.output / 'SHA256SUMS').write_text(''.join(checksums)) + print(f'Unpublished source bundle prepared: {args.output}') + + +if __name__ == '__main__': + main() diff --git a/scripts/test/browser-acceptance.mjs b/scripts/test/browser-acceptance.mjs new file mode 100644 index 000000000..76680b924 --- /dev/null +++ b/scripts/test/browser-acceptance.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +// Real-service acceptance uses disposable fixtures, never routed responses or an in-memory Judge. +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { chromium } from 'playwright' + +const consoleOrigin = localOrigin(process.env.ACCEPTANCE_CONSOLE_ORIGIN || 'http://127.0.0.1:9002') +const managementOrigin = localOrigin(process.env.ACCEPTANCE_MANAGEMENT_ORIGIN || 'http://127.0.0.1:9003') +const username = process.env.ACCEPTANCE_USERNAME +const password = process.env.ACCEPTANCE_PASSWORD +assert(username && password, 'Supply disposable ACCEPTANCE_USERNAME and ACCEPTANCE_PASSWORD') +assert.equal(process.env.ACCEPTANCE_DISPOSABLE, 'true', 'Explicit disposable-stack opt-in is required') +const output = resolve(process.env.ACCEPTANCE_OUTPUT || '.local/browser-acceptance') +await mkdir(output, { recursive: true }) +const report = { startedAt: new Date().toISOString(), evidence: 'real-local-services', checks: [] } +const browser = await chromium.launch({ + headless: true, + ...(process.env.ACCEPTANCE_CHROMIUM ? { executablePath: process.env.ACCEPTANCE_CHROMIUM } : {}), +}) +const timer = setTimeout(() => { console.error('Acceptance exceeded five minutes'); browser.close().catch(() => {}) }, 300_000) +try { + const context = await browser.newContext({ locale: 'en-US' }) + const page = await context.newPage() + page.setDefaultTimeout(30_000) + await login(page, consoleOrigin) + await page.waitForURL(`${consoleOrigin}/problemset`) + report.checks.push({ name: 'console-browser-login', passed: true }) + + await page.goto(`${consoleOrigin}/problems/two-sum/description`) + await page.getByRole('button', { name: 'Select programming language' }).click() + await page.getByRole('menuitem', { name: 'Python', exact: true }).click() + // Use Monaco's public text-model API: native EditContext typing may apply + // automatic indentation. Submission still goes through the visible button + // and the real services; verify its exact request body below. + const code = 'class Solution:\n def twoSum(self, nums, target):\n seen = {}\n for i, n in enumerate(nums):\n if target - n in seen:\n return [seen[target - n], i]\n seen[n] = i\n return []\n' + await page.waitForFunction(() => window.__ULTICODE_MONACO__?.editor.getModels().length) + await page.evaluate(value => window.__ULTICODE_MONACO__.editor.getModels()[0].setValue(value), code) + report.checks.push({ name: 'problem-browser-editor', passed: true }) + + const submitted = page.waitForResponse(response => response.request().method() === 'POST' + && /\/problems\/\d+\/submissions$/.test(new URL(response.url()).pathname)) + await page.getByRole('button', { name: 'Submit Solution', exact: true }).click() + const response = await submitted + assert.equal(response.request().postDataJSON().code, code, 'Editor must submit the exact fixture') + assert(response.ok(), `Submission HTTP ${response.status()}`) + const envelope = await response.json() + assert(envelope.data?.id, 'Real submission response must contain its ID') + const id = envelope.data.id + report.submissionId = id + report.checks.push({ name: 'browser-submission-intake', passed: true }) + let verdict + for (let attempt = 0; attempt < 60; attempt++) { + const readback = await page.request.get(`${consoleOrigin}/api/submissions/${encodeURIComponent(id)}`) + assert(readback.ok(), `Submission readback HTTP ${readback.status()}`) + verdict = (await readback.json()).data?.status + if (verdict && !['pending', 'judging', 'queued', 'running'].includes(normalize(verdict))) break + await page.waitForTimeout(2_000) + } + report.verdict = verdict + assert.equal(normalize(verdict), 'accepted', `Expected real Judge Accepted, observed ${verdict}`) + report.checks.push({ name: 'real-judge-accepted', passed: true }) + + const management = await (await browser.newContext({ locale: 'en-US' })).newPage() + await login(management, managementOrigin, process.env.ACCEPTANCE_MANAGEMENT_USERNAME || username, process.env.ACCEPTANCE_MANAGEMENT_PASSWORD || password) + await management.waitForURL(url => !url.pathname.startsWith('/login')) + report.checks.push({ name: 'management-browser-login', passed: true }) + for (const [path, api] of [['/problems', '/problems'], ['/contests', '/contest']]) { + const loaded = management.waitForResponse(response => response.ok() + && response.request().method() === 'GET' + && new URL(response.url()).pathname.startsWith(`/api/admin${api}`)) + await management.goto(`${managementOrigin}${path}`) + await loaded + await management.getByRole('main').first().waitFor() + report.checks.push({ name: `management${path}-real-read`, passed: true }) + } + // Create an unpublished contest through the real wizard, then read it back. + // Keep the fixture for diagnosis; never delete existing data as test cleanup. + const contestSlug = `acceptance-${Date.now()}` + report.contestSlug = contestSlug + await management.getByRole('button', { name: 'Create Contest', exact: true }).click() + const dialog = management.getByRole('dialog') + await dialog.getByPlaceholder('Enter contest title', { exact: true }).fill(contestSlug) + await dialog.getByPlaceholder('contest-slug', { exact: true }).fill(contestSlug) + await dialog.getByRole('button', { name: 'Next', exact: true }).click() + await dialog.getByRole('button', { name: 'Next', exact: true }).click() + await dialog.locator('input[type="datetime-local"]').fill(new Date(Date.now() + 86_400_000).toISOString().slice(0, 16)) + await dialog.getByRole('button', { name: 'Next', exact: true }).click() + await dialog.getByRole('button', { name: 'Add Problem', exact: true }).click() + await management.getByRole('option').filter({ hasText: 'two-sum' }).click() + await dialog.getByRole('button', { name: 'Next', exact: true }).click() + const contestCreated = management.waitForResponse(response => response.request().method() === 'POST' + && new URL(response.url()).pathname === '/api/admin/contest') + await dialog.getByRole('button', { name: 'Create Contest', exact: true }).click() + const creation = await contestCreated + assert(creation.ok(), `Contest creation HTTP ${creation.status()}`) + const contest = (await creation.json()).data + assert(contest?.id, 'Created contest must have a real ID') + report.contestId = contest.id + assert.equal(creation.request().postDataJSON().isPublished, false, 'Wizard must request an unpublished fixture') + assert.equal(contest.isVisible, false, 'Acceptance fixture must remain invisible') + const contestReadback = await management.request.get(`${managementOrigin}/api/admin/contest/${encodeURIComponent(contest.id)}`) + assert(contestReadback.ok(), 'Contest readback must succeed') + const persistedContest = (await contestReadback.json()).data + assert.equal(persistedContest?.slug, contestSlug) + assert.equal(persistedContest?.isVisible, false) + report.checks.push({ name: 'management-contest-wizard-create-readback', passed: true }) + + const contestsLoaded = page.waitForResponse(response => response.ok() + && new URL(response.url()).pathname === '/api/contest/upcoming') + await page.goto(`${consoleOrigin}/contest`) + await contestsLoaded + await page.getByRole('main').first().waitFor() + report.checks.push({ name: 'contest-browser-entry', passed: true }) + report.passed = true +} catch (error) { + report.passed = false + // Never serialize browser storage, credentials, request bodies or source. + let failure = String(error.message) + for (const secret of [password, process.env.ACCEPTANCE_MANAGEMENT_PASSWORD]) { + if (secret) failure = failure.replaceAll(secret, '[REDACTED]') + } + report.failure = failure.replaceAll(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED]').slice(0, 2000) + process.exitCode = 1 +} finally { + report.finishedAt = new Date().toISOString() + await writeFile(resolve(output, 'report.json'), `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }) + console.log(JSON.stringify(report)) + await browser.close() + clearTimeout(timer) +} + +function localOrigin(value) { + const url = new URL(value) + assert(['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname), 'Acceptance is loopback-only') + assert(url.protocol === 'http:' && !url.username && !url.password && url.pathname === '/' && !url.search && !url.hash, + 'Use a plain disposable HTTP origin') + return url.origin +} +function normalize(value) { return String(value || '').toLowerCase().replaceAll(/[_\s-]/g, '') } +async function login(page, origin, loginUsername = username, loginPassword = password) { + await page.goto(`${origin}/login`) + await page.getByLabel('Username', { exact: true }).fill(loginUsername) + await page.getByLabel('Password', { exact: true }).fill(loginPassword) + await page.getByRole('button', { name: 'Login', exact: true }).click() +} diff --git a/services/admin/src/main/java/com/ulticode/modules/admin/dto/settings/AllSettingsVO.java b/services/admin/src/main/java/com/ulticode/modules/admin/dto/settings/AllSettingsVO.java index 95e1b386a..692f39cc4 100644 --- a/services/admin/src/main/java/com/ulticode/modules/admin/dto/settings/AllSettingsVO.java +++ b/services/admin/src/main/java/com/ulticode/modules/admin/dto/settings/AllSettingsVO.java @@ -50,5 +50,5 @@ public class AllSettingsVO { private boolean featureNotifications; private boolean featureBookmarks; private boolean featureProblemLists; - private boolean featureAgent = true; + private boolean featureAgent; } diff --git a/services/admin/src/main/java/com/ulticode/modules/admin/dto/settings/FeatureTogglesVO.java b/services/admin/src/main/java/com/ulticode/modules/admin/dto/settings/FeatureTogglesVO.java index 567d6ee5f..ec8fe7280 100644 --- a/services/admin/src/main/java/com/ulticode/modules/admin/dto/settings/FeatureTogglesVO.java +++ b/services/admin/src/main/java/com/ulticode/modules/admin/dto/settings/FeatureTogglesVO.java @@ -8,7 +8,7 @@ */ @Data public class FeatureTogglesVO { - private boolean featureAgent = true; + private boolean featureAgent; @JsonIgnore private boolean featureAgentSpecified; diff --git a/services/admin/src/main/java/com/ulticode/modules/admin/service/impl/SystemSettingsServiceImpl.java b/services/admin/src/main/java/com/ulticode/modules/admin/service/impl/SystemSettingsServiceImpl.java index 56a58a6fb..b65a104e7 100644 --- a/services/admin/src/main/java/com/ulticode/modules/admin/service/impl/SystemSettingsServiceImpl.java +++ b/services/admin/src/main/java/com/ulticode/modules/admin/service/impl/SystemSettingsServiceImpl.java @@ -198,7 +198,7 @@ public UploadSettingsVO updateUploadSettings(UploadSettingsVO vo) { @Override public boolean isAgentEnabled() { Map raw = store.loadAllRaw(List.of(SystemSettingsStore.KEY_FEATURES)); - if (!raw.containsKey(SystemSettingsStore.KEY_FEATURES)) return true; + if (!raw.containsKey(SystemSettingsStore.KEY_FEATURES)) return false; FeatureTogglesVO flags = store.parseOrDefault(raw.get(SystemSettingsStore.KEY_FEATURES), FeatureTogglesVO.class, () -> { throw new BusinessException(AdminErrorCode.SETTING_INVALID_VALUE); diff --git a/services/admin/src/test/java/com/ulticode/modules/admin/service/impl/SystemSettingsServiceImplTest.java b/services/admin/src/test/java/com/ulticode/modules/admin/service/impl/SystemSettingsServiceImplTest.java index 14a12d5be..9d1f4c855 100644 --- a/services/admin/src/test/java/com/ulticode/modules/admin/service/impl/SystemSettingsServiceImplTest.java +++ b/services/admin/src/test/java/com/ulticode/modules/admin/service/impl/SystemSettingsServiceImplTest.java @@ -84,11 +84,11 @@ void setUp() { @Test void agentAvailabilityRejectsCorruptStoredSettings() { - assertThat(service.isAgentEnabled()).isTrue(); + assertThat(service.isAgentEnabled()).isFalse(); store.rows.put(SystemSettingsStore.KEY_FEATURES, "broken-json"); assertThatThrownBy(() -> service.isAgentEnabled()).isInstanceOf(BusinessException.class); store.rows.put(SystemSettingsStore.KEY_FEATURES, "{\"featureForum\":true}"); - assertThat(service.isAgentEnabled()).isTrue(); + assertThat(service.isAgentEnabled()).isFalse(); store.rows.put(SystemSettingsStore.KEY_FEATURES, "{\"featureAgent\":false}"); assertThat(service.isAgentEnabled()).isFalse(); } @@ -103,14 +103,18 @@ void agentOnlyConfigurationSavesButEmptyRequestStillFails() throws Exception { } @Test - void agentDefaultsOnForOldSettingsAndCanBeDisabled() throws Exception { - assertThat(service.getFeatureToggles().isFeatureAgent()).isTrue(); + void agentDefaultsOffForOldSettingsAndCanBeExplicitlyEnabled() throws Exception { + assertThat(service.getFeatureToggles().isFeatureAgent()).isFalse(); FeatureTogglesVO legacy = objectMapper.readValue("{\"featureForum\":true}", FeatureTogglesVO.class); - assertThat(legacy.isFeatureAgent()).isTrue(); + assertThat(legacy.isFeatureAgent()).isFalse(); + legacy.setFeatureAgent(true); + service.updateFeatureToggles(legacy); + assertThat(service.getFeatureToggles().isFeatureAgent()).isTrue(); + assertThat(service.getAllSettings().isFeatureAgent()).isTrue(); + assertThat(service.isAgentEnabled()).isTrue(); legacy.setFeatureAgent(false); service.updateFeatureToggles(legacy); - assertThat(service.getFeatureToggles().isFeatureAgent()).isFalse(); - assertThat(service.getAllSettings().isFeatureAgent()).isFalse(); + assertThat(service.isAgentEnabled()).isFalse(); } @Nested diff --git a/services/agent/data/repository_corpus_manifest.json b/services/agent/data/repository_corpus_manifest.json index bb7182e00..6bfbe3bbf 100644 --- a/services/agent/data/repository_corpus_manifest.json +++ b/services/agent/data/repository_corpus_manifest.json @@ -1,8 +1,8 @@ [ { "doc_id": "repository-product", - "version": "sha256-b91a53f649bf485653c4f17cb85b566367d47fef92cabcad1892b1ab09b71908", - "chunk_id": "repository-product:sha256-b91a53f649bf485653c4f17cb85b566367d47fef92cabcad1892b1ab09b71908:1", + "version": "sha256-0dfbb3a50fa456955dc04050223b527feb36ae4ec7e6dfabe53817f8a8b46271", + "chunk_id": "repository-product:sha256-0dfbb3a50fa456955dc04050223b527feb36ae4ec7e6dfabe53817f8a8b46271:1", "source_path": "docs/PRODUCT.md", "access_scope": "repository-public", "sample_kind": "real", @@ -11,7 +11,7 @@ "source_position": "lines 46-46", "model_input_projection": "SourceHit.as_model_dict()", "source_trust": "untrusted-data", - "content_digest": "sha256:c51b724da7cc4bb0bbb40a5ccfa91589fb29250593a2d16a87c83c5191ce8b2e" + "content_digest": "sha256:292bc96e93f944cfc99229d7da3fe1471fe55237287c9ff2fc27baa29cadfcb1" }, { "doc_id": "repository-architecture", @@ -29,8 +29,8 @@ }, { "doc_id": "repository-development", - "version": "sha256-ac417e4707bba3bde1cd0d27afc27f4b30bc00840b4a7b310c8f3751df8aebfb", - "chunk_id": "repository-development:sha256-ac417e4707bba3bde1cd0d27afc27f4b30bc00840b4a7b310c8f3751df8aebfb:1", + "version": "sha256-958962d6a78179fc23e05e190b624ee08128ddfc212fa01cabe902e44d6a971a", + "chunk_id": "repository-development:sha256-958962d6a78179fc23e05e190b624ee08128ddfc212fa01cabe902e44d6a971a:1", "source_path": "docs/DEVELOPMENT.md", "access_scope": "repository-public", "sample_kind": "real", @@ -43,14 +43,14 @@ }, { "doc_id": "repository-operations", - "version": "sha256-bca5f07a744f78fdf8480eb9daeab266628a48bb0ef5c5e9f393ebef6b798547", - "chunk_id": "repository-operations:sha256-bca5f07a744f78fdf8480eb9daeab266628a48bb0ef5c5e9f393ebef6b798547:1", + "version": "sha256-62e7604861666186f9c127231c3e1990286d310f3b15891b2cbe15042576f847", + "chunk_id": "repository-operations:sha256-62e7604861666186f9c127231c3e1990286d310f3b15891b2cbe15042576f847:1", "source_path": "docs/OPERATIONS.md", "access_scope": "repository-public", "sample_kind": "real", "permission": "MIT:LICENSE:sha256:2be36e9d56578c1111740b6fe339ea3619cf1f2000af718f1a59e97d2e5d995c", "scope": "Repository documentation; local retrieval and evaluation under the included MIT license", - "source_position": "lines 46-50", + "source_position": "lines 57-61", "model_input_projection": "SourceHit.as_model_dict()", "source_trust": "untrusted-data", "content_digest": "sha256:e1ff3e78b8192a25aabf19db0fff20115b87b67b653240fbb31a0eba8a9b979e" From 3f1b0dc391e87d8dbc72ee3174f06824d2c8741d Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Mon, 26 Oct 2026 23:26:59 -0700 Subject: [PATCH 6/8] test(readiness): exercise business lifecycle and expose problem create blocker --- CHANGELOG.md | 4 + .../src/api/admin/__tests__/problems.spec.ts | 10 + apps/management/src/api/admin/problems.ts | 17 +- docs/DEVELOPMENT.md | 23 ++ scripts/test/browser-business-acceptance.mjs | 209 ++++++++++++++++++ .../data/repository_corpus_manifest.json | 4 +- 6 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 scripts/test/browser-business-acceptance.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 25bdfb182..75e18278c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ This is release-candidate preparation, not an official stable release or a production-readiness declaration. +- Normalize Management problem difficulty at the API boundary, with regression tests. +- Add extended disposable business acceptance; problem creation remains a release + blocker because owner ID generation and the complete create-command contract + are not aligned. Do not claim problem CRUD acceptance. - Restore Management's language-menu CI contract and test actual selection-event handling and the current-language indicator. - Refresh Agent repository-document version binding while preserving license, diff --git a/apps/management/src/api/admin/__tests__/problems.spec.ts b/apps/management/src/api/admin/__tests__/problems.spec.ts index 169b8063c..0fa4378a6 100644 --- a/apps/management/src/api/admin/__tests__/problems.spec.ts +++ b/apps/management/src/api/admin/__tests__/problems.spec.ts @@ -1,3 +1,4 @@ +import { Difficulty } from '@/api/admin/problems' import { describe, it, expect, vi, beforeEach } from 'vitest' import { problemsApi, @@ -106,6 +107,15 @@ describe('problemsApi', () => { }) describe('serializeCreateInput', () => { + it.each([['EASY', 'Easy'], ['MEDIUM', 'Medium'], ['HARD', 'Hard']])( + 'uses the Java DTO difficulty spelling for %s on create and update', + (input, expected) => { + expect(serializeCreateInput({ slug: 'test', title: 'Test', difficulty: input as Difficulty }).difficulty).toBe(expected) + expect(serializeUpdateInput({ difficulty: input as Difficulty }).difficulty).toBe(expected) + }, + ) + + it('should serialize examples, constraints, hints to JSON strings', () => { const input: ProblemCreateInput = { slug: 'test', diff --git a/apps/management/src/api/admin/problems.ts b/apps/management/src/api/admin/problems.ts index b3ca0ac4d..759b583c3 100644 --- a/apps/management/src/api/admin/problems.ts +++ b/apps/management/src/api/admin/problems.ts @@ -137,10 +137,12 @@ export interface ProblemQueryParams { // ========== API Transport DTOs (match backend Java DTOs exactly) ========== +export type WireDifficulty = 'Easy' | 'Medium' | 'Hard' + export interface CreateProblemDto { slug: string title: string - difficulty: Difficulty + difficulty: WireDifficulty status?: ProblemStatus isPremium?: boolean isPublished?: boolean @@ -162,7 +164,7 @@ export interface LanguageConfig { export interface UpdateProblemDto { slug?: string title?: string - difficulty?: Difficulty + difficulty?: WireDifficulty status?: ProblemStatus isPremium?: boolean hasSolution?: boolean @@ -221,9 +223,19 @@ export interface ProblemUpdateInput { // ========== Serialization Helpers ========== +function wireDifficulty(value: string): WireDifficulty { + switch (value.toUpperCase()) { + case 'EASY': return 'Easy' + case 'MEDIUM': return 'Medium' + case 'HARD': return 'Hard' + default: throw new Error('Unsupported problem difficulty') + } +} + export function serializeCreateInput(input: ProblemCreateInput): CreateProblemDto { return { ...input, + difficulty: wireDifficulty(input.difficulty), examples: input.examples ? JSON.stringify(input.examples.map((ex, idx) => ({ ...ex, order: ex.order ?? idx }))) : undefined, @@ -235,6 +247,7 @@ export function serializeCreateInput(input: ProblemCreateInput): CreateProblemDt export function serializeUpdateInput(input: ProblemUpdateInput): UpdateProblemDto { return { ...input, + difficulty: input.difficulty === undefined ? undefined : wireDifficulty(input.difficulty), examples: input.examples ? JSON.stringify(input.examples.map((ex, idx) => ({ ...ex, order: ex.order ?? idx }))) : undefined, diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 91f5bb2ad..61e9b13ef 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -119,6 +119,29 @@ Accepted、Management 数据读取与未发布 contest 的创建/readback,不 报告在 `.local/browser-acceptance/report.json`,不保存 cookies、凭据或源码。提交与 未发布 contest fixture 保留用于诊断,不自动删除数据。此检查不是生产、性能或模型验收。 +### 扩展业务验收与当前阻塞 + +基础报告通过后,`scripts/test/browser-business-acceptance.mjs` 使用同一组已有测试账号, +验证真实 Wrong Answer、Management problem create/edit/readback、重判与 contest +注册、提交、live ranking、结束和最终 ranking。额外需要 CREATE/UPDATE:PROBLEM 与 +UPDATE:CONTEST 权限;不得修改全局角色策略。它会发布并结束基础脚本创建的 disposable +contest,因此完整重跑前应先运行基础脚本创建新 fixture。可用 `ACCEPTANCE_CHECKS` +逗号分隔选择检查;报告只代表实际选中的检查,不代表整个验收通过。 + +```bash +ACCEPTANCE_DISPOSABLE=true node scripts/test/browser-business-acceptance.mjs +``` + +当前实测:真实 Python Wrong Answer、重判 Pending → Judging → Accepted,以及 +contest 从注册到 FINISHED 最终排名通过。 +Management 表单 difficulty 必须转换为后端接受的 Easy/Medium/Hard,不能直接发送 +UI 的 EASY/MEDIUM/HARD。修复此边界后,problem create 仍返回 500:App 的 Problem +实体使用 AUTO ID,但已安装 owner schema 的 `problems.id` 没有自动值;此外 +CreateProblemCommand 当前只携带 title/slug/author,未传递完整创建表单。 +这属于发布阻塞,不能把已有题目读取或 Judge 验收当成 problem CRUD 验收。 +修复必须同时覆盖 owner ID、完整写入/readback 与数据库集成测试,不能修改已应用迁移、 +跳过必填字段或直接在测试库 ALTER 来制造通过。 + ### 已准备环境启动 ```bash diff --git a/scripts/test/browser-business-acceptance.mjs b/scripts/test/browser-business-acceptance.mjs new file mode 100644 index 000000000..3c1b34672 --- /dev/null +++ b/scripts/test/browser-business-acceptance.mjs @@ -0,0 +1,209 @@ +#!/usr/bin/env node +// Additional real-business checks on existing disposable accounts and contest. +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { chromium } from 'playwright' + +assert.equal(process.env.ACCEPTANCE_DISPOSABLE, 'true') +const user = process.env.ACCEPTANCE_USERNAME +const password = process.env.ACCEPTANCE_PASSWORD +const manager = process.env.ACCEPTANCE_MANAGEMENT_USERNAME +const managerPassword = process.env.ACCEPTANCE_MANAGEMENT_PASSWORD +assert(user && password && manager && managerPassword, 'Existing disposable credentials are required') +const consoleOrigin = 'http://127.0.0.1:9002' +const managementOrigin = 'http://127.0.0.1:9003' +const prior = JSON.parse(await readFile('.local/browser-acceptance/report.json', 'utf8')) +assert(prior.passed && prior.submissionId && prior.contestId, 'Run the base acceptance first') +const selected = process.env.ACCEPTANCE_CHECKS ? new Set(process.env.ACCEPTANCE_CHECKS.split(',')) : null +const output = '.local/browser-business-acceptance' +await mkdir(output, { recursive: true }) +const report = { startedAt: new Date().toISOString(), evidence: 'real-disposable-services', checks: [], fixtures: { submissionId: prior.submissionId, contestId: prior.contestId, contestSlug: prior.contestSlug } } +const browser = await chromium.launch({ headless: true, ...(process.env.ACCEPTANCE_CHROMIUM ? { executablePath: process.env.ACCEPTANCE_CHROMIUM } : {}) }) +const timer = setTimeout(() => browser.close().catch(() => {}), 600_000) +try { + const participant = await (await browser.newContext({ locale: 'en-US' })).newPage() + const admin = await (await browser.newContext({ locale: 'en-US' })).newPage() + await login(participant, consoleOrigin, user, password) + await login(admin, managementOrigin, manager, managerPassword) + + await check('real-wrong-answer-terminal', async () => { + await openEditor(participant, `${consoleOrigin}/problems/two-sum/description`) + const id = await submit(participant, 'class Solution:\n def twoSum(self, nums, target):\n return []\n') + report.fixtures.wrongAnswerSubmissionId = id + await save() + report.wrongAnswerStates = await terminal(participant, id, 'wronganswer') + }) + + await check('management-problem-create-edit-readback', async () => { + const slug = `acceptance-problem-${Date.now()}` + report.fixtures.problemSlug = slug + await admin.goto(`${managementOrigin}/problems/create`) + await admin.getByPlaceholder('Enter problem title', { exact: true }).fill(slug) + await admin.getByPlaceholder('two-sum', { exact: true }).fill(slug) + await admin.getByPlaceholder('Enter problem content...', { exact: true }).fill('Return the supplied integer. Synthetic acceptance fixture.') + await admin.getByPlaceholder('Enter test input...', { exact: true }).fill('1') + await admin.getByPlaceholder('Enter expected output...', { exact: true }).fill('1') + const constraint = admin.getByPlaceholder('e.g., 1 <= nums.length <= 10^4', { exact: true }) + await constraint.fill('1 <= n <= 10') + await constraint.press('Enter') + const created = admin.waitForResponse(r => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/admin/problems') + await admin.getByRole('button', { name: 'Create Problem', exact: true }).click() + const response = await created + const envelope = await response.json() + assert(response.ok(), `Problem create HTTP ${response.status()}: ${envelope.message}`) + const data = envelope.data + assert(data?.id, 'Created problem must have an ID') + report.fixtures.problemId = data.id + await save() + assert.equal((await api(admin, `/admin/problems/${data.id}`)).title, slug) + await admin.goto(`${managementOrigin}/problems/${data.id}/edit/description`) + await admin.getByPlaceholder('Enter problem title', { exact: true }).fill(`${slug}-edited`) + const updated = admin.waitForResponse(r => r.request().method() === 'PATCH' && new URL(r.url()).pathname === `/api/admin/problems/${data.id}`) + await admin.getByRole('button', { name: 'Update Description', exact: true }).click() + assert((await updated).ok(), 'Problem update must succeed') + const readback = await api(admin, `/admin/problems/${data.id}`) + assert.equal(readback.title, `${slug}-edited`) + assert.equal(readback.slug, slug) + }) + + await check('management-rejudge-progression-terminal', async () => { + const id = prior.submissionId + await admin.goto(`${managementOrigin}/submissions`) + await admin.getByPlaceholder('Search submissions...', { exact: true }).fill(id) + const row = admin.locator('tr').filter({ hasText: id.slice(0, 8) }) + await row.getByRole('button', { name: 'Rejudge', exact: true }).click() + const receipt = admin.waitForResponse(r => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/admin/submissions/${id}/rejudge`) + await admin.getByRole('dialog').getByRole('button', { name: 'Rejudge', exact: true }).click() + const response = await receipt + assert(response.ok(), `Rejudge HTTP ${response.status()}`) + const data = (await response.json()).data + assert.equal(data?.success, true) + assert.equal(normalize(data.newStatus), 'pending') + assert(Number(data.retryCount) >= 1) + report.rejudgeReceipt = { oldStatus: data.oldStatus, newStatus: data.newStatus, retryCount: data.retryCount } + await save() + report.rejudgeStates = await terminal(participant, id, 'accepted') + }) + + await check('contest-participant-submission-ranking-lifecycle', async () => { + const id = prior.contestId + const slug = prior.contestSlug + let contest = await api(admin, `/admin/contest/${id}`) + if (!contest.isVisible) contest = await api(admin, `/admin/contest/${id}`, 'PATCH', { isPublished: true }) + assert.equal(contest.isVisible, true) + await participant.goto(`${consoleOrigin}/contest/${slug}`) + const participation = await api(participant, `/contest/${slug}/participation`) + if (normalize(participation.status) === 'notparticipated') { + const registered = participant.waitForResponse(r => r.request().method() === 'POST' && new URL(r.url()).pathname.endsWith('/register')) + await participant.getByRole('button', { name: 'Register', exact: true }).first().click() + assert((await registered).ok(), 'Participant registration must succeed') + } + report.contestRegistered = true + await save() + if (contest.status === 'UPCOMING') contest = await api(admin, `/admin/contest/${id}/start`, 'POST') + assert.equal(contest.status, 'RUNNING') + report.contestStarted = true + await save() + await participant.goto(`${consoleOrigin}/contest/${slug}`) + await participant.locator('#contest-problems a[href*="contestId="]').first().click() + await participant.getByRole('button', { name: 'Select programming language' }).click() + await participant.getByRole('menuitem', { name: 'Python', exact: true }).click() + const submissionId = await submit(participant, 'class Solution:\n def twoSum(self, nums, target):\n seen = {}\n for i, n in enumerate(nums):\n if target-n in seen: return [seen[target-n], i]\n seen[n] = i\n return []\n', true) + report.fixtures.contestSubmissionId = submissionId + await save() + report.contestSubmissionStates = await terminal(participant, submissionId, 'accepted') + let ranking + for (let attempt = 0; attempt < 60; attempt++) { + const data = await api(participant, `/contest/${slug}/live-ranking`) + const entries = Array.isArray(data) ? data : data.items || data.records || [] + ranking = entries.find(entry => entry.username === user) + if (ranking && Number(ranking.problemsSolved) >= 1) break + await participant.waitForTimeout(1000) + } + assert(ranking, 'Participant must appear in real ranking') + report.ranking = { rank: ranking.rank, solved: ranking.problemsSolved, score: ranking.score } + assert(Number(report.ranking.solved) >= 1) + const ended = await api(admin, `/admin/contest/${id}/end`, 'POST') + assert(['FINISHING', 'FINISHED'].includes(ended.status)) + let final = ended + for (let attempt = 0; attempt < 60 && final.status !== 'FINISHED'; attempt++) { + await participant.waitForTimeout(1000) + final = await api(admin, `/admin/contest/${id}`) + } + assert.equal(final.status, 'FINISHED') + const snapshot = await api(participant, `/contest/${slug}/ranking`) + const entries = snapshot.items || [] + const finalRank = entries.find(entry => entry.username === user) + assert(finalRank && Number(finalRank.problemsSolved) >= 1, 'Final ranking must retain the solved problem') + report.finalRanking = { rank: finalRank.rank, problemsSolved: finalRank.problemsSolved, score: finalRank.score } + report.contestFinalStatus = final.status + }) +} finally { + report.passed = report.checks.length === (selected?.size || 4) && report.checks.every(check => check.passed) + report.finishedAt = new Date().toISOString() + await save() + console.log(JSON.stringify(report)) + process.exitCode = report.passed ? 0 : 1 + await browser.close() + clearTimeout(timer) +} + +async function save() { await writeFile(`${output}/report.json`, JSON.stringify(report, null, 2) + '\n', { mode: 0o600 }) } +async function check(name, action) { + if (selected && !selected.has(name)) return + try { await action(); report.checks.push({ name, passed: true }) } + catch (error) { + let message = String(error.message) + for (const secret of [password, managerPassword]) message = message.replaceAll(secret, '[REDACTED]') + message = message.replaceAll(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[REDACTED]') + report.checks.push({ name, passed: false, failure: message.slice(0, 1600) }) + } + await save() +} +async function login(page, origin, username, secret) { + await page.goto(`${origin}/login`) + await page.getByLabel('Username', { exact: true }).fill(username) + await page.getByLabel('Password', { exact: true }).fill(secret) + await page.getByRole('button', { name: 'Login', exact: true }).click() + await page.waitForURL(url => !url.pathname.startsWith('/login')) +} +async function api(page, path, method = 'GET', data) { + const origin = page === undefined ? consoleOrigin : new URL(page.url()).origin + const csrf = (await page.context().cookies()).find(cookie => cookie.name === 'csrf_token')?.value + const response = await page.request.fetch(`${origin}/api${path}`, { method, timeout: 15000, ...(data ? { data } : {}), ...(method !== 'GET' && csrf ? { headers: { 'X-CSRF-Token': decodeURIComponent(csrf) } } : {}) }) + assert(response.ok(), `${method} ${path} HTTP ${response.status()}`) + const value = await response.json() + assert.equal(value.code, 0, `${method} ${path} rejected: ${value.message}`) + return value.data +} +async function openEditor(page, url) { + await page.goto(url) + await page.getByRole('button', { name: 'Select programming language' }).click() + await page.getByRole('menuitem', { name: 'Python', exact: true }).click() +} +async function submit(page, code, contest = false) { + await page.waitForFunction(() => window.__ULTICODE_MONACO__?.editor.getModels().length) + await page.evaluate(value => window.__ULTICODE_MONACO__.editor.getModels()[0].setValue(value), code) + const receipt = page.waitForResponse(r => r.request().method() === 'POST' && (contest ? /\/contest\/[^/]+\/problems\/\d+\/submissions$/ : /\/problems\/\d+\/submissions$/).test(new URL(r.url()).pathname)) + await page.getByRole('button', { name: 'Submit Solution', exact: true }).click() + const response = await receipt + assert.equal(response.request().postDataJSON().code, code) + assert(response.ok(), `Submission HTTP ${response.status()}`) + const value = await response.json() + assert(value.data?.id, `Submission rejected: ${value.message}`) + return value.data.id +} +async function terminal(page, id, expected) { + const states = [] + for (let attempt = 0; attempt < 90; attempt++) { + const data = await api(page, `/submissions/${id}`) + if (states.at(-1) !== data.status) states.push(data.status) + if (!['pending', 'judging', 'queued', 'running'].includes(normalize(data.status))) { + assert.equal(normalize(data.status), expected, `Unexpected terminal verdict: ${data.status}`) + return states + } + await page.waitForTimeout(1000) + } + throw Error(`No terminal verdict after 90 polls; states=${states.join(',')}`) +} +function normalize(value) { return String(value || '').toLowerCase().replaceAll(/[_\s-]/g, '') } diff --git a/services/agent/data/repository_corpus_manifest.json b/services/agent/data/repository_corpus_manifest.json index 6bfbe3bbf..5eaf1db99 100644 --- a/services/agent/data/repository_corpus_manifest.json +++ b/services/agent/data/repository_corpus_manifest.json @@ -29,8 +29,8 @@ }, { "doc_id": "repository-development", - "version": "sha256-958962d6a78179fc23e05e190b624ee08128ddfc212fa01cabe902e44d6a971a", - "chunk_id": "repository-development:sha256-958962d6a78179fc23e05e190b624ee08128ddfc212fa01cabe902e44d6a971a:1", + "version": "sha256-8cfeb0bcf95da547d66ef979c7d8d9e717d68577d3c2f425d0639a1fdb77e112", + "chunk_id": "repository-development:sha256-8cfeb0bcf95da547d66ef979c7d8d9e717d68577d3c2f425d0639a1fdb77e112:1", "source_path": "docs/DEVELOPMENT.md", "access_scope": "repository-public", "sample_kind": "real", From f3ce6e3a22c38554028640c49a4c76a408112991 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Mon, 26 Oct 2026 23:59:00 -0700 Subject: [PATCH 7/8] fix(problem): preserve complete owner writes and generated IDs --- CHANGELOG.md | 8 ++- docs/DEVELOPMENT.md | 22 ++++--- ...__Restore_Server_Generated_Problem_Ids.sql | 7 +++ ...owner-migration-safety-integration-test.sh | 22 +++++++ scripts/test/browser-business-acceptance.mjs | 57 +++++++++++++++++++ .../admin/dto/problem/AdminProblemMapper.java | 4 +- .../admin/dto/problem/CreateProblemDTO.java | 2 + .../admin/service/ProblemCutoverService.java | 19 ++++++- .../dto/problem/AdminProblemMapperTest.java | 34 +++++++++++ .../service/ProblemCutoverServiceTest.java | 23 +++++++- .../data/repository_corpus_manifest.json | 4 +- .../app/api/command/CreateProblemCommand.java | 9 ++- .../app/api/command/UpdateProblemCommand.java | 11 +++- .../app/api/dto/ProblemWriteFields.java | 16 ++++++ .../ProblemAdministrationProvider.java | 51 ++++++++++++++++- .../port/DefaultProblemDetailPort.java | 2 + .../ProblemAdministrationProviderTest.java | 48 ++++++++++++++++ .../port/DefaultProblemDetailPortTest.java | 10 ++++ ...roblemAdministrationDomainServiceImpl.java | 14 +++++ ...roblemAdministrationDomainServiceTest.java | 4 +- 20 files changed, 344 insertions(+), 23 deletions(-) create mode 100644 init-db/migrations/app/V20261027070000__Restore_Server_Generated_Problem_Ids.sql create mode 100644 services/admin/src/test/java/com/ulticode/modules/admin/dto/problem/AdminProblemMapperTest.java create mode 100644 services/api/app-api/src/main/java/com/ulticode/app/api/dto/ProblemWriteFields.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e18278c..a98051d72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,11 @@ This is release-candidate preparation, not an official stable release or a production-readiness declaration. - Normalize Management problem difficulty at the API boundary, with regression tests. -- Add extended disposable business acceptance; problem creation remains a release - blocker because owner ID generation and the complete create-command contract - are not aligned. Do not claim problem CRUD acceptance. +- Restore database-generated problem IDs with an additive owner migration; + preserve existing IDs and validated test-case references. Carry complete + create/update fields across the owner seam and return nested detail readback. + Persist detail before the first version snapshot and roll back rejected child + writes. Extend real-service acceptance through persisted create/edit and Console visibility. - Restore Management's language-menu CI contract and test actual selection-event handling and the current-language indicator. - Refresh Agent repository-document version binding while preserving license, diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 61e9b13ef..4ccdd5a67 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -93,6 +93,9 @@ export SUBMISSION_CUTOVER_COMPLETE=true 不能复用于生产。生成的 `.env`、PM2 state、ACL、日志和数据库都不能提交。 中断时保留卷并检查日志及正在运行的进程;不要重跑首次安装检查或删除卷来掩盖失败。 恢复迁移可显式传入上述 `MIGRATION_MYSQL_CONTAINER`,无需安装 host mysql CLI。 +若本地 Maven 编译进程触发已有 PM2 1 GiB memory restart,可在 disposable `.env` +设置 `JAVA_TOOL_OPTIONS="-Xms128m -Xmx512m"` 后通过支持的 launcher 刷新进程; +本次验收使用该本地 heap 上限,未提高或关闭 PM2 memory gate。 health 通过只证明 readiness。发布前仍须用真实 Auth/App/Submission/Judge 验证浏览器 登录、题目、提交和 terminal verdict,以及 Management/contest 权限与业务操作; @@ -133,14 +136,17 @@ ACCEPTANCE_DISPOSABLE=true node scripts/test/browser-business-acceptance.mjs ``` 当前实测:真实 Python Wrong Answer、重判 Pending → Judging → Accepted,以及 -contest 从注册到 FINISHED 最终排名通过。 -Management 表单 difficulty 必须转换为后端接受的 Easy/Medium/Hard,不能直接发送 -UI 的 EASY/MEDIUM/HARD。修复此边界后,problem create 仍返回 500:App 的 Problem -实体使用 AUTO ID,但已安装 owner schema 的 `problems.id` 没有自动值;此外 -CreateProblemCommand 当前只携带 title/slug/author,未传递完整创建表单。 -这属于发布阻塞,不能把已有题目读取或 Judge 验收当成 problem CRUD 验收。 -修复必须同时覆盖 owner ID、完整写入/readback 与数据库集成测试,不能修改已应用迁移、 -跳过必填字段或直接在测试库 ALTER 来制造通过。 +contest 从注册到 FINISHED 最终排名通过。Management 创建、编辑、完整字段持久化 +readback、发布后 Console 可见性,以及非法子项创建/更新的事务回滚均已通过。 +Management 表单 difficulty 在传输层转换为后端接受的 Easy/Medium/Hard。 +Problem create/update 通过完整的类型化 Owner payload 传递详情、例子、语言与标签; +partial update 保留未提供字段,原有 optimistic version 与授权约束不变。 +新增 Owner migration `V20261027070000` 恢复 `problems.id` 的数据库生成策略,保留 +已有 ID 与引用,并重新验证相同的 test_cases 外键;不关闭 foreign_key_checks。 +新建详情在初始版本快照前保存,API 可选 summary 按存储契约初始化为空字符串。 +RPC 返回失败值前显式标记写事务回滚,避免子项拒绝后留下部分创建或覆盖已有内容。 +Admin description readback 映射 nested detail,不能把读取已存在题目或单独 Judge +验收当成创建/编辑验收。fresh/upgrade ID 与外键保持检查属于真实 MySQL migration drill。 ### 已准备环境启动 diff --git a/init-db/migrations/app/V20261027070000__Restore_Server_Generated_Problem_Ids.sql b/init-db/migrations/app/V20261027070000__Restore_Server_Generated_Problem_Ids.sql new file mode 100644 index 000000000..2d136feea --- /dev/null +++ b/init-db/migrations/app/V20261027070000__Restore_Server_Generated_Problem_Ids.sql @@ -0,0 +1,7 @@ +-- Keep existing IDs/references and database-generated IDs for new problems. +-- MySQL requires removing the referencing constraint before changing this attribute. +-- Re-add the identical constraint with validation; never disable foreign_key_checks. +ALTER TABLE test_cases DROP FOREIGN KEY fk_test_cases_problem_id; +ALTER TABLE problems MODIFY COLUMN id BIGINT NOT NULL AUTO_INCREMENT; +ALTER TABLE test_cases ADD CONSTRAINT fk_test_cases_problem_id + FOREIGN KEY (problem_id) REFERENCES problems (id); diff --git a/scripts/dev/owner-migration-safety-integration-test.sh b/scripts/dev/owner-migration-safety-integration-test.sh index 8626de3fa..1f4844651 100755 --- a/scripts/dev/owner-migration-safety-integration-test.sh +++ b/scripts/dev/owner-migration-safety-integration-test.sh @@ -147,6 +147,28 @@ for OWNER_SCHEMA in auth app; do done printf 'Auth/App owner migrations: PASS\n' +# Fresh owner chain must allocate IDs, preserve references, and retain FK enforcement. +mysql_root -e "INSERT INTO app.problems (slug,title,difficulty) VALUES ('id-fresh-check','Fresh ID check','Easy');" +FRESH_PROBLEM_ID="$(mysql_root -N -B -e "SELECT id FROM app.problems WHERE slug='id-fresh-check'")" +[[ "$FRESH_PROBLEM_ID" -gt 0 ]] +# Rehearse the same additive migration against pre-existing IDs and a live reference. +mysql_root -e "CREATE DATABASE problem_id_upgrade; +CREATE TABLE problem_id_upgrade.problems (id BIGINT NOT NULL PRIMARY KEY, slug VARCHAR(120)); +CREATE TABLE problem_id_upgrade.test_cases (id VARCHAR(40) PRIMARY KEY, problem_id BIGINT, + CONSTRAINT fk_test_cases_problem_id FOREIGN KEY(problem_id) REFERENCES problem_id_upgrade.problems(id)); +INSERT INTO problem_id_upgrade.problems VALUES (73,'existing'); +INSERT INTO problem_id_upgrade.test_cases VALUES ('existing-case',73);" +mysql_root problem_id_upgrade -e "$(cat "$ROOT_DIR/init-db/migrations/app/V20261027070000__Restore_Server_Generated_Problem_Ids.sql")" +mysql_root -e "INSERT INTO problem_id_upgrade.problems (slug) VALUES ('new');" +[[ "$(mysql_root -N -B -e "SELECT id FROM problem_id_upgrade.problems WHERE slug='new'")" -gt 73 ]] +[[ "$(mysql_root -N -B -e "SELECT COUNT(*) FROM problem_id_upgrade.test_cases t JOIN problem_id_upgrade.problems p ON t.problem_id=p.id WHERE p.id=73 AND p.slug='existing'")" == 1 ]] +if mysql_root -e "INSERT INTO problem_id_upgrade.test_cases VALUES ('orphan',999999)" > "$TEST_DIR/orphan.log" 2>&1; then + echo 'problem ID migration lost FK enforcement' >&2 + exit 1 +fi +printf 'Problem IDs fresh/upgrade/reference preservation: PASS\n' + + env ENV_FILE="$TEST_ENV" MIGRATION_DB_HOST=127.0.0.1 \ MIGRATION_DB_PORT="$MYSQL_TEST_PORT" MIGRATION_DB_NAME=ulticode \ MIGRATION_DB_USER=root MIGRATION_DB_PASSWORD="$ROOT_PASSWORD" \ diff --git a/scripts/test/browser-business-acceptance.mjs b/scripts/test/browser-business-acceptance.mjs index 3c1b34672..6c6867756 100644 --- a/scripts/test/browser-business-acceptance.mjs +++ b/scripts/test/browser-business-acceptance.mjs @@ -41,6 +41,11 @@ try { await admin.getByPlaceholder('Enter problem title', { exact: true }).fill(slug) await admin.getByPlaceholder('two-sum', { exact: true }).fill(slug) await admin.getByPlaceholder('Enter problem content...', { exact: true }).fill('Return the supplied integer. Synthetic acceptance fixture.') + await admin.getByPlaceholder('Enter problem summary...', { exact: true }).fill('Synthetic summary') + await admin.getByPlaceholder('Add a hint...', { exact: true }).fill('Return n') + await admin.getByPlaceholder('Add a hint...', { exact: true }).press('Enter') + await admin.getByPlaceholder('Add language...', { exact: true }).fill('python') + await admin.getByPlaceholder('Add language...', { exact: true }).press('Enter') await admin.getByPlaceholder('Enter test input...', { exact: true }).fill('1') await admin.getByPlaceholder('Enter expected output...', { exact: true }).fill('1') const constraint = admin.getByPlaceholder('e.g., 1 <= nums.length <= 10^4', { exact: true }) @@ -56,6 +61,16 @@ try { report.fixtures.problemId = data.id await save() assert.equal((await api(admin, `/admin/problems/${data.id}`)).title, slug) + const description = await api(admin, `/admin/problems/${data.id}/description`) + assert.equal(description.difficulty.toLowerCase(), 'medium') + assert.equal(description.isPublished, false) + assert.equal(description.detail.summary, 'Synthetic summary') + assert.equal(description.detail.content, 'Return the supplied integer. Synthetic acceptance fixture.') + assert.deepEqual(description.detail.constraintsJson, ['1 <= n <= 10']) + assert.deepEqual(description.detail.hints, ['Return n']) + assert.equal(description.examples[0].input, '1') + assert.equal(description.examples[0].output, '1') + assert((await api(admin, `/admin/problems/${data.id}/code`)).languages.some(language => language.value === 'python')) await admin.goto(`${managementOrigin}/problems/${data.id}/edit/description`) await admin.getByPlaceholder('Enter problem title', { exact: true }).fill(`${slug}-edited`) const updated = admin.waitForResponse(r => r.request().method() === 'PATCH' && new URL(r.url()).pathname === `/api/admin/problems/${data.id}`) @@ -64,6 +79,41 @@ try { const readback = await api(admin, `/admin/problems/${data.id}`) assert.equal(readback.title, `${slug}-edited`) assert.equal(readback.slug, slug) + // A real partial update must retain the title and persist every supplied owner section. + await api(admin, `/admin/problems/${data.id}`, 'PATCH', { + difficulty: 'Hard', isPublished: true, summary: 'Edited summary', content: 'Edited synthetic content.', + constraintsJson: '["2 <= n <= 20"]', hints: '["Edited hint"]', + examples: '[{"input":"2","output":"2","explanation":"identity"}]', + languages: [{ language: 'python', starterCode: 'class Solution:\n pass\n' }], tags: [], + }) + const edited = await api(admin, `/admin/problems/${data.id}/description`) + assert.equal(edited.title, `${slug}-edited`) + assert.equal(edited.difficulty.toLowerCase(), 'hard') + assert.equal(edited.isPublished, true) + assert.equal(edited.detail.summary, 'Edited summary') + assert.equal(edited.detail.content, 'Edited synthetic content.') + assert.deepEqual(edited.detail.constraintsJson, ['2 <= n <= 20']) + assert.deepEqual(edited.detail.hints, ['Edited hint']) + assert.equal(edited.examples[0].input, '2') + assert.equal(edited.examples[0].output, '2') + const code = await api(admin, `/admin/problems/${data.id}/code`) + assert.equal(code.languages.find(language => language.value === 'python').starterCode, 'class Solution:\n pass\n') + const invalidSlug = `${slug}-invalid`; + await rejectedWrite(admin, '/admin/problems', 'POST', { + slug: invalidSlug, title: invalidSlug, difficulty: 'Easy', content: 'Must roll back', languages: ['unsupported-language'], + }) + assert.equal((await api(admin, `/admin/problems?search=${invalidSlug}`)).total, 0) + await rejectedWrite(admin, `/admin/problems/${data.id}`, 'PATCH', { + title: 'Must not persist', content: 'Must roll back', languages: [{ language: 'unsupported-language', starterCode: 'invalid' }], + }) + const afterRejected = await api(admin, `/admin/problems/${data.id}/description`) + assert.equal(afterRejected.title, `${slug}-edited`) + assert.equal(afterRejected.detail.content, 'Edited synthetic content.') + assert((await api(admin, `/admin/problems/${data.id}/code`)).languages.some(language => language.value === 'python')) + report.problemInvalidChildRollback = true + await participant.goto(`${consoleOrigin}/problems/${slug}/description`) + await participant.getByText('Edited synthetic content.', { exact: true }).waitFor() + report.problemPersistedReadback = { id: data.id, difficulty: edited.difficulty, published: edited.isPublished, sections: ['title', 'summary', 'content', 'constraints', 'hints', 'examples', 'languages', 'tags'], consoleVisible: true } }) await check('management-rejudge-progression-terminal', async () => { @@ -176,6 +226,13 @@ async function api(page, path, method = 'GET', data) { assert.equal(value.code, 0, `${method} ${path} rejected: ${value.message}`) return value.data } +async function rejectedWrite(page, path, method, data) { + const csrf = (await page.context().cookies()).find(cookie => cookie.name === 'csrf_token')?.value + const response = await page.request.fetch(`${managementOrigin}/api${path}`, { method, data, + headers: { 'X-CSRF-Token': decodeURIComponent(csrf) }, timeout: 15000 }) + assert.equal(response.status(), 400, 'Invalid child section must be rejected as bad input') + assert.notEqual((await response.json()).code, 0) +} async function openEditor(page, url) { await page.goto(url) await page.getByRole('button', { name: 'Select programming language' }).click() diff --git a/services/admin/src/main/java/com/ulticode/modules/admin/dto/problem/AdminProblemMapper.java b/services/admin/src/main/java/com/ulticode/modules/admin/dto/problem/AdminProblemMapper.java index b4b5b6ab0..8b4dfa13a 100644 --- a/services/admin/src/main/java/com/ulticode/modules/admin/dto/problem/AdminProblemMapper.java +++ b/services/admin/src/main/java/com/ulticode/modules/admin/dto/problem/AdminProblemMapper.java @@ -39,10 +39,12 @@ public interface AdminProblemMapper { @Mapping(target = "publishedAt", source = "problem.publishedAt") @Mapping(target = "createdAt", source = "problem.createdAt") @Mapping(target = "updatedAt", source = "problem.updatedAt") + @Mapping(target = "detail", expression = "java(toDetailInfo(dto))") DescriptionDataVO toDescriptionDataVO(ProblemAdminDescriptionDTO dto); default DescriptionDataVO.DetailInfo toDetailInfo(ProblemAdminDescriptionDTO dto) { - if (dto == null) { + if (dto == null || (dto.summary() == null && dto.content() == null + && dto.constraintsJson() == null && dto.hints() == null)) { return null; } DescriptionDataVO.DetailInfo info = new DescriptionDataVO.DetailInfo(); diff --git a/services/admin/src/main/java/com/ulticode/modules/admin/dto/problem/CreateProblemDTO.java b/services/admin/src/main/java/com/ulticode/modules/admin/dto/problem/CreateProblemDTO.java index 8b0b62e51..38f5ba87e 100644 --- a/services/admin/src/main/java/com/ulticode/modules/admin/dto/problem/CreateProblemDTO.java +++ b/services/admin/src/main/java/com/ulticode/modules/admin/dto/problem/CreateProblemDTO.java @@ -44,5 +44,7 @@ public class CreateProblemDTO { private String hints; + private List languages; + private List tags; } diff --git a/services/admin/src/main/java/com/ulticode/modules/admin/service/ProblemCutoverService.java b/services/admin/src/main/java/com/ulticode/modules/admin/service/ProblemCutoverService.java index 3ffd1d7d2..d5386cfb0 100644 --- a/services/admin/src/main/java/com/ulticode/modules/admin/service/ProblemCutoverService.java +++ b/services/admin/src/main/java/com/ulticode/modules/admin/service/ProblemCutoverService.java @@ -1,5 +1,6 @@ package com.ulticode.modules.admin.service; +import com.ulticode.app.api.dto.ProblemWriteFields; import com.ulticode.app.api.command.CreateProblemCommand; import com.ulticode.app.api.command.DeleteProblemCommand; import com.ulticode.app.api.command.PublishProblemCommand; @@ -62,7 +63,14 @@ public ProblemAdminVO createProblem(CreateProblemDTO createDTO) { envelope.commandId(), envelope.idempotency(), envelope.actor(), envelope.trace(), createDTO.getSlug(), createDTO.getTitle(), - actorId)); + actorId, + new ProblemWriteFields( + createDTO.getSlug(), createDTO.getDifficulty(), createDTO.getIsPremium(), + createDTO.getIsPublished(), null, createDTO.getSummary(), createDTO.getContent(), + createDTO.getExamples(), createDTO.getConstraints(), createDTO.getHints(), + createDTO.getLanguages() == null ? null : createDTO.getLanguages().stream() + .map(language -> new ProblemWriteFields.Language(language, null)).toList(), + createDTO.getTags()))); if (result == null || !result.success()) { throw AdminOwnerErrorMapper.mapOwnerError(AdminOwnerErrorMapper.Owner.PROBLEM, result); } @@ -82,7 +90,14 @@ public ProblemAdminVO updateProblem(Long id, UpdateProblemDTO updateDTO) { idStr, current.version(), updateDTO.getTitle(), - "cutover update")); + "cutover update", + new ProblemWriteFields( + updateDTO.getSlug(), updateDTO.getDifficulty(), updateDTO.getIsPremium(), + updateDTO.getIsPublished(), updateDTO.getHasSolution(), updateDTO.getSummary(), updateDTO.getContent(), + updateDTO.getExamples(), updateDTO.getConstraintsJson(), updateDTO.getHints(), + updateDTO.getLanguages() == null ? null : updateDTO.getLanguages().stream() + .map(language -> new ProblemWriteFields.Language(language.getLanguage(), language.getStarterCode())).toList(), + updateDTO.getTags()))); if (result == null || !result.success()) { throw AdminOwnerErrorMapper.mapOwnerError(AdminOwnerErrorMapper.Owner.PROBLEM, result); } diff --git a/services/admin/src/test/java/com/ulticode/modules/admin/dto/problem/AdminProblemMapperTest.java b/services/admin/src/test/java/com/ulticode/modules/admin/dto/problem/AdminProblemMapperTest.java new file mode 100644 index 000000000..13de57320 --- /dev/null +++ b/services/admin/src/test/java/com/ulticode/modules/admin/dto/problem/AdminProblemMapperTest.java @@ -0,0 +1,34 @@ +package com.ulticode.modules.admin.dto.problem; + +import com.ulticode.app.api.dto.ProblemAdminDescriptionDTO; +import com.ulticode.app.api.dto.ProblemAdminRowDTO; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AdminProblemMapperTest { + private final AdminProblemMapper mapper = Mappers.getMapper(AdminProblemMapper.class); + + @Test + void descriptionReadbackIncludesPersistedDetailSections() { + var row = mock(ProblemAdminRowDTO.class); + when(row.id()).thenReturn(7L); + var dto = new ProblemAdminDescriptionDTO(row, "summary", "content", List.of("constraint"), List.of("hint"), List.of(), List.of()); + var result = mapper.toDescriptionDataVO(dto); + assertThat(result.getId()).isEqualTo("7"); + assertThat(result.getDetail().getSummary()).isEqualTo("summary"); + assertThat(result.getDetail().getContent()).isEqualTo("content"); + assertThat(result.getDetail().getConstraintsJson()).containsExactly("constraint"); + assertThat(result.getDetail().getHints()).containsExactly("hint"); + } + + @Test + void absentDetailRemainsAbsent() { + var row = mock(ProblemAdminRowDTO.class); + var dto = new ProblemAdminDescriptionDTO(row, null, null, null, null, List.of(), List.of()); + assertThat(mapper.toDescriptionDataVO(dto).getDetail()).isNull(); + } +} diff --git a/services/admin/src/test/java/com/ulticode/modules/admin/service/ProblemCutoverServiceTest.java b/services/admin/src/test/java/com/ulticode/modules/admin/service/ProblemCutoverServiceTest.java index b760d1c40..4b5ba15b8 100644 --- a/services/admin/src/test/java/com/ulticode/modules/admin/service/ProblemCutoverServiceTest.java +++ b/services/admin/src/test/java/com/ulticode/modules/admin/service/ProblemCutoverServiceTest.java @@ -79,6 +79,11 @@ void createViaDubbo() { CreateProblemDTO dto = new CreateProblemDTO(); dto.setSlug("two-sum"); dto.setTitle("Two Sum"); + dto.setDifficulty("Hard"); + dto.setIsPublished(false); + dto.setContent("Complete content"); + dto.setConstraints("[\"limit\"]"); + dto.setLanguages(java.util.List.of("python")); when(dubboProvider.createProblem(any())).thenReturn(RpcResult.success( new com.ulticode.app.api.dto.ProblemAdminViewDTO( "42", "two-sum", "Two Sum", 1L, "todo"), @@ -95,7 +100,12 @@ void createViaDubbo() { command.trace() != null && command.trace().traceId() != null && !command.trace().traceId().isBlank() - && "admin-1".equals(command.actor().actorId()))); + && "admin-1".equals(command.actor().actorId()) + && "Hard".equals(command.fields().difficulty()) + && Boolean.FALSE.equals(command.fields().isPublished()) + && "Complete content".equals(command.fields().content()) + && "[\"limit\"]".equals(command.fields().constraintsJson()) + && "python".equals(command.fields().languages().get(0).language()))); verify(problemReadPort).findBySlug("two-sum"); assertThat(result.getTitle()).isEqualTo("Two Sum"); } @@ -122,6 +132,12 @@ void publishViaDubbo() { void updateForwardsVersion() { UpdateProblemDTO dto = new UpdateProblemDTO(); dto.setTitle("Updated"); + dto.setDifficulty("Easy"); + dto.setHints("[\"hint\"]"); + var language = new UpdateProblemDTO.LanguageConfig(); + language.setLanguage("python"); + language.setStarterCode("starter"); + dto.setLanguages(java.util.List.of(language)); ProblemAdminRowDTO row = row(42L, "two-sum", "Two Sum"); when(problemReadPort.findProblem(42L)).thenReturn(row); when(dubboProvider.updateProblem(any())).thenReturn(RpcResult.success( @@ -135,7 +151,10 @@ void updateForwardsVersion() { ProblemAdminVO result = cutoverService.updateProblem(42L, dto); verify(dubboProvider).updateProblem(argThat(command -> - command.expectedVersion().equals(1L))); + command.expectedVersion().equals(1L) + && "Easy".equals(command.fields().difficulty()) + && "[\"hint\"]".equals(command.fields().hints()) + && "starter".equals(command.fields().languages().get(0).starterCode()))); assertThat(result.getTitle()).isEqualTo("Updated"); } diff --git a/services/agent/data/repository_corpus_manifest.json b/services/agent/data/repository_corpus_manifest.json index 5eaf1db99..4e57583ba 100644 --- a/services/agent/data/repository_corpus_manifest.json +++ b/services/agent/data/repository_corpus_manifest.json @@ -29,8 +29,8 @@ }, { "doc_id": "repository-development", - "version": "sha256-8cfeb0bcf95da547d66ef979c7d8d9e717d68577d3c2f425d0639a1fdb77e112", - "chunk_id": "repository-development:sha256-8cfeb0bcf95da547d66ef979c7d8d9e717d68577d3c2f425d0639a1fdb77e112:1", + "version": "sha256-cae402bd326c114bc1c4acef873c477d7ae1fbd371358f1c1a251c582f301ad9", + "chunk_id": "repository-development:sha256-cae402bd326c114bc1c4acef873c477d7ae1fbd371358f1c1a251c582f301ad9:1", "source_path": "docs/DEVELOPMENT.md", "access_scope": "repository-public", "sample_kind": "real", diff --git a/services/api/app-api/src/main/java/com/ulticode/app/api/command/CreateProblemCommand.java b/services/api/app-api/src/main/java/com/ulticode/app/api/command/CreateProblemCommand.java index 5ff1a2db7..2306346b4 100644 --- a/services/api/app-api/src/main/java/com/ulticode/app/api/command/CreateProblemCommand.java +++ b/services/api/app-api/src/main/java/com/ulticode/app/api/command/CreateProblemCommand.java @@ -1,6 +1,7 @@ package com.ulticode.app.api.command; import java.io.Serializable; +import com.ulticode.app.api.dto.ProblemWriteFields; import com.ulticode.common.command.ActorDelegation; import com.ulticode.common.command.WriteCommand; @@ -27,10 +28,16 @@ public record CreateProblemCommand( TraceMetadata trace, String slug, String title, - String authorAccountId) implements WriteCommand, Serializable { + String authorAccountId, + ProblemWriteFields fields) implements WriteCommand, Serializable { private static final long serialVersionUID = 1L; + /** Compatibility constructor for the original minimal contract. */ + public CreateProblemCommand(String commandId, IdMetadata idempotency, ActorDelegation actor, TraceMetadata trace, String slug, String title, String authorAccountId) { + this(commandId, idempotency, actor, trace, slug, title, authorAccountId, null); + } + public CreateProblemCommand { if (commandId == null || commandId.isBlank()) { throw new IllegalArgumentException( diff --git a/services/api/app-api/src/main/java/com/ulticode/app/api/command/UpdateProblemCommand.java b/services/api/app-api/src/main/java/com/ulticode/app/api/command/UpdateProblemCommand.java index 633028815..f7a54bd05 100644 --- a/services/api/app-api/src/main/java/com/ulticode/app/api/command/UpdateProblemCommand.java +++ b/services/api/app-api/src/main/java/com/ulticode/app/api/command/UpdateProblemCommand.java @@ -1,6 +1,7 @@ package com.ulticode.app.api.command; import java.io.Serializable; +import com.ulticode.app.api.dto.ProblemWriteFields; import com.ulticode.common.command.ActorDelegation; import com.ulticode.common.command.WriteCommand; @@ -28,10 +29,16 @@ public record UpdateProblemCommand( String problemId, Long expectedVersion, String title, - String rationale) implements WriteCommand, Serializable { + String rationale, + ProblemWriteFields fields) implements WriteCommand, Serializable { private static final long serialVersionUID = 1L; + /** Compatibility constructor for the original minimal contract. */ + public UpdateProblemCommand(String commandId, IdMetadata idempotency, ActorDelegation actor, TraceMetadata trace, String problemId, Long expectedVersion, String title, String rationale) { + this(commandId, idempotency, actor, trace, problemId, expectedVersion, title, rationale, null); + } + public UpdateProblemCommand { if (commandId == null || commandId.isBlank()) { throw new IllegalArgumentException( @@ -45,7 +52,7 @@ public record UpdateProblemCommand( throw new IllegalArgumentException( "expectedVersion is required for optimistic locking"); } - if (title == null || title.isBlank()) { + if ((title != null && title.isBlank()) || (fields == null && title == null)) { throw new IllegalArgumentException("title is required"); } if (idempotency == null) { diff --git a/services/api/app-api/src/main/java/com/ulticode/app/api/dto/ProblemWriteFields.java b/services/api/app-api/src/main/java/com/ulticode/app/api/dto/ProblemWriteFields.java new file mode 100644 index 000000000..ad8dbda7b --- /dev/null +++ b/services/api/app-api/src/main/java/com/ulticode/app/api/dto/ProblemWriteFields.java @@ -0,0 +1,16 @@ +package com.ulticode.app.api.dto; + +import java.io.Serializable; +import java.util.List; + +/** Complete owner write payload. Null fields on update retain existing values. */ +public record ProblemWriteFields( + String slug, String difficulty, Boolean isPremium, Boolean isPublished, + Boolean hasSolution, String summary, String content, String examples, + String constraintsJson, String hints, List languages, List tags) + implements Serializable { + private static final long serialVersionUID = 1L; + public record Language(String language, String starterCode) implements Serializable { + private static final long serialVersionUID = 1L; + } +} diff --git a/services/app/app-web/src/main/java/com/ulticode/app/dubbo/provider/ProblemAdministrationProvider.java b/services/app/app-web/src/main/java/com/ulticode/app/dubbo/provider/ProblemAdministrationProvider.java index 6c7989803..73be2a2af 100644 --- a/services/app/app-web/src/main/java/com/ulticode/app/dubbo/provider/ProblemAdministrationProvider.java +++ b/services/app/app-web/src/main/java/com/ulticode/app/dubbo/provider/ProblemAdministrationProvider.java @@ -5,6 +5,8 @@ import com.ulticode.app.api.command.PublishProblemCommand; import com.ulticode.app.api.command.UpdateProblemCommand; import com.ulticode.app.api.dto.ProblemAdminViewDTO; +import com.ulticode.app.api.dto.ProblemWriteFields; +import com.ulticode.modules.problem.dto.LanguageConfigDTO; import com.ulticode.app.api.error.AppErrorCode; import com.ulticode.app.api.service.ProblemAdministrationService; import com.ulticode.common.error.BaseErrorCode; @@ -50,14 +52,30 @@ public RpcResult createProblem(CreateProblemCommand command CreateProblemDTO dto = new CreateProblemDTO(); dto.setSlug(command.slug()); dto.setTitle(command.title()); + if (command.fields() != null) { + var f = command.fields(); + dto.setDifficulty(f.difficulty()); + dto.setIsPremium(f.isPremium()); + dto.setIsPublished(f.isPublished()); + dto.setSummary(f.summary()); + dto.setContent(f.content()); + dto.setExamples(f.examples()); + dto.setHints(f.hints()); + dto.setTags(f.tags()); + dto.setConstraints(f.constraintsJson()); + dto.setLanguages(f.languages() == null ? null : f.languages().stream() + .map(ProblemWriteFields.Language::language).toList()); + } String actorId = command.actor() != null ? command.actor().actorId() : null; Problem entity = domainService.createProblem(dto, actorId); indexRefresher.publish(entity); return RpcResult.success(toAdminView(entity.getId(), entity.getSlug(), entity.getTitle(), entity, entity.getStatus()), command.trace().traceId()); } catch (BusinessException e) { + rollbackWrite(); return toFailure(e, command.trace().traceId()); } catch (Exception e) { + rollbackWrite(); log.error("ProblemAdministrationProvider.createProblem unexpected error", e); return RpcResult.failure(AppErrorCode.UNEXPECTED_APP_STATE, command.trace().traceId()); } @@ -81,19 +99,48 @@ public RpcResult updateProblem(UpdateProblemCommand command try { UpdateProblemDTO dto = new UpdateProblemDTO(); dto.setTitle(command.title()); + if (command.fields() != null) { + var f = command.fields(); + dto.setDifficulty(f.difficulty()); + dto.setIsPremium(f.isPremium()); + dto.setIsPublished(f.isPublished()); + dto.setSummary(f.summary()); + dto.setContent(f.content()); + dto.setExamples(f.examples()); + dto.setHints(f.hints()); + dto.setTags(f.tags()); + dto.setConstraintsJson(f.constraintsJson()); + dto.setSlug(f.slug()); + dto.setHasSolution(f.hasSolution()); + dto.setLanguages(f.languages() == null ? null : f.languages().stream().map(value -> { + var language = new LanguageConfigDTO(); + language.setLanguage(value.language()); + language.setStarterCode(value.starterCode()); + return language; + }).toList()); + } String actorId = command.actor() != null ? command.actor().actorId() : null; Problem entity = domainService.updateProblem(id, dto, actorId, command.expectedVersion()); indexRefresher.publish(entity); return RpcResult.success(toAdminView(entity.getId(), entity.getSlug(), entity.getTitle(), entity, entity.getStatus()), command.trace().traceId()); } catch (BusinessException e) { + rollbackWrite(); return toFailure(e, command.trace().traceId()); } catch (Exception e) { + rollbackWrite(); log.error("ProblemAdministrationProvider.updateProblem unexpected error id={}", id, e); return RpcResult.failure(AppErrorCode.UNEXPECTED_APP_STATE, command.trace().traceId()); } } + // RPC failures are returned as values, so the transaction interceptor cannot infer rollback. + private static void rollbackWrite() { + if (org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive()) { + org.springframework.transaction.interceptor.TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + } + } + @Override @Transactional public RpcResult publishProblem(PublishProblemCommand command) { @@ -199,7 +246,9 @@ private static ProblemAdminViewDTO toAdminView(Long id, String slug, String titl private static RpcResult toFailure(BusinessException e, String traceId) { int code = e.getErrorCode() != null ? e.getErrorCode().code() : -1; AppErrorCode mapped; - if (code == BaseErrorCode.NOT_FOUND.code() || code == 30001) { + if (code == BaseErrorCode.BAD_REQUEST.code() || code == BaseErrorCode.VALIDATION_FAILED.code()) { + mapped = AppErrorCode.BAD_REQUEST; + } else if (code == BaseErrorCode.NOT_FOUND.code() || code == 30001) { mapped = AppErrorCode.CONTENT_NOT_FOUND; } else if (code == AppErrorCode.VERSION_CONFLICT.code()) { mapped = AppErrorCode.VERSION_CONFLICT; diff --git a/services/app/app-web/src/main/java/com/ulticode/modules/problem/port/DefaultProblemDetailPort.java b/services/app/app-web/src/main/java/com/ulticode/modules/problem/port/DefaultProblemDetailPort.java index 256e8e384..db9f391b4 100644 --- a/services/app/app-web/src/main/java/com/ulticode/modules/problem/port/DefaultProblemDetailPort.java +++ b/services/app/app-web/src/main/java/com/ulticode/modules/problem/port/DefaultProblemDetailPort.java @@ -100,6 +100,8 @@ private void upsertDetailContent(Long problemId, Problem problem, UpdateProblemD if (detail == null) { detail = new ProblemDetail(); detail.setProblemId(problemId); + // Summary is optional on the HTTP write contract but NOT NULL in storage. + detail.setSummary(""); detail.setId(uuidGenerator.newId().replace("-", "")); // problem_details.slug NOT NULL — denormalize from Problem on insert. // Defensive null check: problems.slug is also NOT NULL in DB, so this diff --git a/services/app/app-web/src/test/java/com/ulticode/app/dubbo/provider/ProblemAdministrationProviderTest.java b/services/app/app-web/src/test/java/com/ulticode/app/dubbo/provider/ProblemAdministrationProviderTest.java index 1fd5b4d76..843353a60 100644 --- a/services/app/app-web/src/test/java/com/ulticode/app/dubbo/provider/ProblemAdministrationProviderTest.java +++ b/services/app/app-web/src/test/java/com/ulticode/app/dubbo/provider/ProblemAdministrationProviderTest.java @@ -89,6 +89,54 @@ private static TraceMetadata trace() { return new TraceMetadata("t-12345", null, null, null); } + @Test + void completePayloadSurvivesCreateAndPartialUpdate() { + var fields = new com.ulticode.app.api.dto.ProblemWriteFields( + "new-slug", "Hard", true, false, true, "summary", "content", "[]", "[\"limit\"]", "[\"hint\"]", + java.util.List.of(new com.ulticode.app.api.dto.ProblemWriteFields.Language("python", "starter")), + java.util.List.of("Array")); + var entity = problemEntity(42L, "new-slug", "Title", 1); + when(domainService.createProblem(any(), eq("admin-1"))).thenReturn(entity); + when(domainService.updateProblem(eq(42L), any(), eq("admin-1"), eq(1L))).thenReturn(entity); + assertThat(provider.createProblem(new CreateProblemCommand(UUID.randomUUID().toString(), IdMetadata.mint(), + adminActor(), trace(), "new-slug", "Title", "admin-1", fields)).success()).isTrue(); + var create = org.mockito.ArgumentCaptor.forClass(CreateProblemDTO.class); + verify(domainService).createProblem(create.capture(), eq("admin-1")); + assertThat(create.getValue().getDifficulty()).isEqualTo("Hard"); + assertThat(create.getValue().getIsPublished()).isFalse(); + assertThat(create.getValue().getContent()).isEqualTo("content"); + assertThat(create.getValue().getConstraints()).isEqualTo("[\"limit\"]"); + assertThat(create.getValue().getLanguages()).containsExactly("python"); + assertThat(create.getValue().getTags()).containsExactly("Array"); + assertThat(provider.updateProblem(new UpdateProblemCommand(UUID.randomUUID().toString(), IdMetadata.mint(), + adminActor(), trace(), "42", 1L, null, "partial", fields)).success()).isTrue(); + var update = org.mockito.ArgumentCaptor.forClass(UpdateProblemDTO.class); + verify(domainService).updateProblem(eq(42L), update.capture(), eq("admin-1"), eq(1L)); + assertThat(update.getValue().getTitle()).isNull(); + assertThat(update.getValue().getSlug()).isEqualTo("new-slug"); + assertThat(update.getValue().getHasSolution()).isTrue(); + assertThat(update.getValue().getSummary()).isEqualTo("summary"); + assertThat(update.getValue().getHints()).isEqualTo("[\"hint\"]"); + assertThat(update.getValue().getExamples()).isEqualTo("[]"); + assertThat(update.getValue().getLanguages().get(0).getStarterCode()).isEqualTo("starter"); + } + + @Test + void failedCompleteCreateMarksTheOwnerTransactionForRollback() { + var status = new org.springframework.transaction.support.SimpleTransactionStatus(); + try (var synchronization = org.mockito.Mockito.mockStatic(org.springframework.transaction.support.TransactionSynchronizationManager.class); + var aspect = org.mockito.Mockito.mockStatic(org.springframework.transaction.interceptor.TransactionAspectSupport.class)) { + synchronization.when(org.springframework.transaction.support.TransactionSynchronizationManager::isActualTransactionActive).thenReturn(true); + aspect.when(org.springframework.transaction.interceptor.TransactionAspectSupport::currentTransactionStatus).thenReturn(status); + when(domainService.createProblem(any(), any())).thenThrow(new BusinessException(BaseErrorCode.VALIDATION_FAILED, "Invalid child section")); + var result = provider.createProblem(new CreateProblemCommand(UUID.randomUUID().toString(), IdMetadata.mint(), + adminActor(), trace(), "invalid-child", "Title", "admin-1")); + assertThat(result.success()).isFalse(); + assertThat(status.isRollbackOnly()).isTrue(); + assertThat(result.error().code()).isEqualTo(AppErrorCode.BAD_REQUEST.code()); + } + } + @Nested @DisplayName("createProblem") class Create { diff --git a/services/app/app-web/src/test/java/com/ulticode/modules/problem/port/DefaultProblemDetailPortTest.java b/services/app/app-web/src/test/java/com/ulticode/modules/problem/port/DefaultProblemDetailPortTest.java index 54da4f5fd..2b0edc96f 100644 --- a/services/app/app-web/src/test/java/com/ulticode/modules/problem/port/DefaultProblemDetailPortTest.java +++ b/services/app/app-web/src/test/java/com/ulticode/modules/problem/port/DefaultProblemDetailPortTest.java @@ -133,6 +133,16 @@ void upsertDetailContent_existingDetail_updatesById() { verify(problemDetailMapper, never()).insert(any(ProblemDetail.class)); } + @Test + void contentOnlyDetailInsertInitializesOptionalSummaryForStorage() { + var dto = new UpdateProblemDTO(); + dto.setContent("content only"); + port.applyDetailUpdate(1L, problem(1L, "content-only"), dto); + verify(problemDetailMapper).insert(argThat((ProblemDetail detail) -> + "".equals(detail.getSummary()) && "content only".equals(detail.getContent()) + && "[]".equals(detail.getConstraintsJson()))); + } + @Test void upsertDetailContent_newDetail_insertsAndDenormalizesSlug() { UpdateProblemDTO dto = new UpdateProblemDTO(); diff --git a/services/app/modules/problem/src/main/java/com/ulticode/modules/problem/service/impl/ProblemAdministrationDomainServiceImpl.java b/services/app/modules/problem/src/main/java/com/ulticode/modules/problem/service/impl/ProblemAdministrationDomainServiceImpl.java index 8f2e89e78..c23c5141d 100644 --- a/services/app/modules/problem/src/main/java/com/ulticode/modules/problem/service/impl/ProblemAdministrationDomainServiceImpl.java +++ b/services/app/modules/problem/src/main/java/com/ulticode/modules/problem/service/impl/ProblemAdministrationDomainServiceImpl.java @@ -4,6 +4,7 @@ import com.ulticode.common.error.BaseErrorCode; import com.ulticode.common.exception.BusinessException; import com.ulticode.modules.problem.dto.CreateProblemDTO; +import com.ulticode.modules.problem.dto.LanguageConfigDTO; import com.ulticode.modules.problem.dto.UpdateProblemDTO; import com.ulticode.modules.problem.entity.Problem; import com.ulticode.modules.problem.port.ProblemDetailDomainPort; @@ -78,6 +79,19 @@ public Problem createProblem(CreateProblemDTO dto, String actorId) { writePort.insert(problem); + UpdateProblemDTO detail = new UpdateProblemDTO(); + detail.setSummary(dto.getSummary()); + detail.setContent(dto.getContent()); + detail.setConstraintsJson(dto.getConstraints()); + detail.setHints(dto.getHints()); + detail.setExamples(dto.getExamples()); + detail.setTags(dto.getTags()); + detail.setLanguages(dto.getLanguages() == null ? null : dto.getLanguages().stream().map(value -> { + var language = new LanguageConfigDTO(); + language.setLanguage(value); + return language; + }).toList()); + detailPort.applyDetailUpdate(problem.getId(), problem, detail); versionPort.createInitialVersion(problem.getId(), actorId); log.info("Problem created: {} by user {}", problem.getId(), actorId); diff --git a/services/app/modules/problem/src/test/java/com/ulticode/modules/problem/service/ProblemAdministrationDomainServiceTest.java b/services/app/modules/problem/src/test/java/com/ulticode/modules/problem/service/ProblemAdministrationDomainServiceTest.java index d8ea11a72..99bd23d2f 100644 --- a/services/app/modules/problem/src/test/java/com/ulticode/modules/problem/service/ProblemAdministrationDomainServiceTest.java +++ b/services/app/modules/problem/src/test/java/com/ulticode/modules/problem/service/ProblemAdministrationDomainServiceTest.java @@ -153,7 +153,9 @@ void success() { assertThat(created.getPublishedBy()).isEqualTo(ACTOR_ID); verify(writePort).insert(any(Problem.class)); - verify(versionPort).createInitialVersion(42L, ACTOR_ID); + var ordered = org.mockito.Mockito.inOrder(detailPort, versionPort); + ordered.verify(detailPort).applyDetailUpdate(eq(42L), eq(created), any(UpdateProblemDTO.class)); + ordered.verify(versionPort).createInitialVersion(42L, ACTOR_ID); } @Test From 0d00bbe9995f81fa4d206bbaec241f286da86b32 Mon Sep 17 00:00:00 2001 From: DavidHLP Date: Tue, 27 Oct 2026 00:03:36 -0700 Subject: [PATCH 8/8] fix(install): align baseline with generated problem IDs --- init-db/baseline/baseline.sql | 14 +++++++------- init-db/scripts/baseline-adopt.sh | 5 +++++ scripts/test/browser-business-acceptance.mjs | 6 ++++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/init-db/baseline/baseline.sql b/init-db/baseline/baseline.sql index 3e9290778..5cddc008a 100644 --- a/init-db/baseline/baseline.sql +++ b/init-db/baseline/baseline.sql @@ -2143,7 +2143,7 @@ CREATE TABLE `virtual_contest_sessions` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-10-01 17:49:57 +-- Dump completed on 2026-10-27 7:01:43 -- -- Dumping schema: auth -- @@ -2401,7 +2401,7 @@ CREATE TABLE `users` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-10-01 17:49:57 +-- Dump completed on 2026-10-27 7:01:43 -- -- Dumping schema: admin -- @@ -2716,7 +2716,7 @@ CREATE TABLE `user_warnings` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-10-01 17:49:58 +-- Dump completed on 2026-10-27 7:01:44 -- -- Dumping schema: app -- @@ -3872,7 +3872,7 @@ CREATE TABLE `problem_versions` ( /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `problems` ( - `id` bigint NOT NULL, + `id` bigint NOT NULL AUTO_INCREMENT, `slug` varchar(120) NOT NULL, `title` varchar(255) NOT NULL, `difficulty` enum('Easy','Medium','Hard') NOT NULL, @@ -4367,7 +4367,7 @@ CREATE TABLE `virtual_contest_sessions` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-10-01 17:49:59 +-- Dump completed on 2026-10-27 7:01:44 -- -- Dumping schema: notification -- @@ -4584,7 +4584,7 @@ CREATE TABLE `notifications` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-10-01 17:49:59 +-- Dump completed on 2026-10-27 7:01:44 -- -- Dumping schema: submission -- @@ -4778,4 +4778,4 @@ CREATE TABLE `submissions` ( /*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; /*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; --- Dump completed on 2026-10-01 17:49:59 +-- Dump completed on 2026-10-27 7:01:44 diff --git a/init-db/scripts/baseline-adopt.sh b/init-db/scripts/baseline-adopt.sh index a58e255f1..7a2d301ed 100755 --- a/init-db/scripts/baseline-adopt.sh +++ b/init-db/scripts/baseline-adopt.sh @@ -62,6 +62,11 @@ if [ "$MODE" = "disposable" ]; then docker exec -i "$CID" mysql -uroot -proot < "$BASELINE_SQL" docker exec "$CID" mysql -uroot -proot -e "$CUTOVER_STATE_SEED_SQL" >/dev/null # Per-schema baseline via flyway docker + # Table-name parity alone cannot detect a stale identity-column definition. + docker exec "$CID" mysql -uroot -proot -e "INSERT INTO app.problems (slug,title,difficulty) VALUES ('baseline-id-check','Baseline ID check','Easy');" >/dev/null + ID_CHECK=$(docker exec "$CID" mysql -uroot -proot -N -B -e "SELECT id FROM app.problems WHERE slug='baseline-id-check';") + [[ "$ID_CHECK" -gt 0 ]] || { echo '[adopt] generated problem ID check failed' >&2; exit 1; } + echo '[adopt] generated problem ID check: PASS' echo "[adopt] baselining shared (ulticode) at $SHARED_VER..." docker run --rm --network "container:$CID" -v "$ROOT/init-db:/flyway/init-db:ro" \ flyway/flyway:10.17.0 -url="jdbc:mysql://127.0.0.1:3306/ulticode?$JDBC_PARAMS" -user=root -password=root \ diff --git a/scripts/test/browser-business-acceptance.mjs b/scripts/test/browser-business-acceptance.mjs index 6c6867756..70272c780 100644 --- a/scripts/test/browser-business-acceptance.mjs +++ b/scripts/test/browser-business-acceptance.mjs @@ -36,6 +36,8 @@ try { await check('management-problem-create-edit-readback', async () => { const slug = `acceptance-problem-${Date.now()}` + const tag = (await api(admin, '/admin/problems/1/description')).tags[0]?.label + assert(tag, 'Seeded problem must expose an existing tag') report.fixtures.problemSlug = slug await admin.goto(`${managementOrigin}/problems/create`) await admin.getByPlaceholder('Enter problem title', { exact: true }).fill(slug) @@ -46,6 +48,8 @@ try { await admin.getByPlaceholder('Add a hint...', { exact: true }).press('Enter') await admin.getByPlaceholder('Add language...', { exact: true }).fill('python') await admin.getByPlaceholder('Add language...', { exact: true }).press('Enter') + await admin.getByPlaceholder('Add tag...', { exact: true }).fill(tag) + await admin.getByPlaceholder('Add tag...', { exact: true }).press('Enter') await admin.getByPlaceholder('Enter test input...', { exact: true }).fill('1') await admin.getByPlaceholder('Enter expected output...', { exact: true }).fill('1') const constraint = admin.getByPlaceholder('e.g., 1 <= nums.length <= 10^4', { exact: true }) @@ -68,6 +72,7 @@ try { assert.equal(description.detail.content, 'Return the supplied integer. Synthetic acceptance fixture.') assert.deepEqual(description.detail.constraintsJson, ['1 <= n <= 10']) assert.deepEqual(description.detail.hints, ['Return n']) + assert.deepEqual(description.tags.map(value => value.label), [tag]) assert.equal(description.examples[0].input, '1') assert.equal(description.examples[0].output, '1') assert((await api(admin, `/admin/problems/${data.id}/code`)).languages.some(language => language.value === 'python')) @@ -94,6 +99,7 @@ try { assert.equal(edited.detail.content, 'Edited synthetic content.') assert.deepEqual(edited.detail.constraintsJson, ['2 <= n <= 20']) assert.deepEqual(edited.detail.hints, ['Edited hint']) + assert.deepEqual(edited.tags, []) assert.equal(edited.examples[0].input, '2') assert.equal(edited.examples[0].output, '2') const code = await api(admin, `/admin/problems/${data.id}/code`)