diff --git a/.gitignore b/.gitignore
index 72c398928f..e3b1d3abc5 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 0000000000..a98051d72b
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,36 @@
+# Changelog
+
+## Unreleased
+
+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.
+- 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,
+ 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 0000000000..3f65965ca2
--- /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 0000000000..3782e19d01
--- /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 0000000000..6d3be2f24c
--- /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/api/admin/__tests__/problems.spec.ts b/apps/management/src/api/admin/__tests__/problems.spec.ts
index 169b8063c9..0fa4378a67 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 b3ca0ac4de..759b583c33 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/apps/management/src/components/LanguageSwitcher.test.ts b/apps/management/src/components/LanguageSwitcher.test.ts
new file mode 100644
index 0000000000..c103d30010
--- /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 af3df1997d..1445bc8819 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/apps/management/src/stores/admin/system-settings.ts b/apps/management/src/stores/admin/system-settings.ts
index 386d4cbe9d..e14586bfe8 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/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index d1ac73c793..351e318907 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/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
index 558d688c4a..4ccdd5a67b 100644
--- a/docs/DEVELOPMENT.md
+++ b/docs/DEVELOPMENT.md
@@ -38,7 +38,117 @@ 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。
+若本地 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 权限与业务操作;
+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 保留用于诊断,不自动删除数据。此检查不是生产、性能或模型验收。
+
+### 扩展业务验收与当前阻塞
+
+基础报告通过后,`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 创建、编辑、完整字段持久化
+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。
+
+### 已准备环境启动
```bash
./scripts/dev/init-env.sh
@@ -109,7 +219,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`,将返回内容作为当前轮的不可信证据传给模型,并记录实际读取轨迹,再依据返回证据回答。源码专用预算日志中的连接失败仅可在官方用量统计与既有回执完整吻合后,按零额外计费核账;保留失败原因、原始备份、证据摘要和调用保留额度,恢复日志区分核账凭据与模型响应回执。
@@ -218,7 +328,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 dd67cddb81..24ed456bbf 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 46d6012dd4..192c2981e1 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/init-db/baseline/baseline.sql b/init-db/baseline/baseline.sql
index 3e9290778c..5cddc008a2 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/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 0000000000..2d136feea1
--- /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/init-db/scripts/baseline-adopt.sh b/init-db/scripts/baseline-adopt.sh
index a58e255f17..7a2d301edd 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/dev/owner-migration-safety-integration-test.sh b/scripts/dev/owner-migration-safety-integration-test.sh
index 72625b0bb2..1f48446515 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" \
@@ -343,6 +365,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/prepare-release-bundle.py b/scripts/runbooks/prepare-release-bundle.py
new file mode 100644
index 0000000000..7c8e255739
--- /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/runbooks/submission-schema-cutover.sh b/scripts/runbooks/submission-schema-cutover.sh
index ea75add5fa..f5a3769db5 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/scripts/test/browser-acceptance.mjs b/scripts/test/browser-acceptance.mjs
new file mode 100644
index 0000000000..76680b9246
--- /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/scripts/test/browser-business-acceptance.mjs b/scripts/test/browser-business-acceptance.mjs
new file mode 100644
index 0000000000..70272c7807
--- /dev/null
+++ b/scripts/test/browser-business-acceptance.mjs
@@ -0,0 +1,272 @@
+#!/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()}`
+ 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)
+ 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('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 })
+ 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)
+ 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.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'))
+ 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)
+ // 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.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`)
+ 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 () => {
+ 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 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()
+ 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/scripts/test/promote-release-contract.py b/scripts/test/promote-release-contract.py
index 70dcd4aac6..d71a20a30a 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 c875a498b1..f999f970bb 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 9490aade20..19822ea7c2 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/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 b4b5b6ab0b..8b4dfa13ae 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 8b0b62e519..38f5ba87e4 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/dto/settings/AllSettingsVO.java b/services/admin/src/main/java/com/ulticode/modules/admin/dto/settings/AllSettingsVO.java
index 95e1b386a6..692f39cc44 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 567d6ee5fc..ec8fe7280f 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/ProblemCutoverService.java b/services/admin/src/main/java/com/ulticode/modules/admin/service/ProblemCutoverService.java
index 3ffd1d7d22..d5386cfb09 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/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 56a58a6fb6..b65a104e71 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/dto/problem/AdminProblemMapperTest.java b/services/admin/src/test/java/com/ulticode/modules/admin/dto/problem/AdminProblemMapperTest.java
new file mode 100644
index 0000000000..13de573205
--- /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 b760d1c404..4b5ba15b80 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/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 14a12d5bec..9d1f4c8558 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 bfa1be19b8..4e57583bad 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,12 +11,12 @@
"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",
- "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",
@@ -29,8 +29,8 @@
},
{
"doc_id": "repository-development",
- "version": "sha256-709a16027041441823f9df0a2e4010e088f119402c7518273b73f3aad2deed2b",
- "chunk_id": "repository-development:sha256-709a16027041441823f9df0a2e4010e088f119402c7518273b73f3aad2deed2b:1",
+ "version": "sha256-cae402bd326c114bc1c4acef873c477d7ae1fbd371358f1c1a251c582f301ad9",
+ "chunk_id": "repository-development:sha256-cae402bd326c114bc1c4acef873c477d7ae1fbd371358f1c1a251c582f301ad9: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"
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 5ff1a2db72..2306346b40 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 633028815b..f7a54bd05b 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 0000000000..ad8dbda7b4
--- /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 6c79898032..73be2a2af7 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 256e8e3843..db9f391b44 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/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 90dfc0b5e0..081dce1809 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 c238a68887..37998a8312 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/app/dubbo/provider/ProblemAdministrationProviderTest.java b/services/app/app-web/src/test/java/com/ulticode/app/dubbo/provider/ProblemAdministrationProviderTest.java
index 1fd5b4d76f..843353a60e 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 54da4f5fda..2b0edc96f4 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/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 cf47376410..fbb0f9a3ee 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 0000000000..f9771b714e
--- /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());
+ }
+}
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 8f2e89e781..c23c5141d3 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 d8ea11a722..99bd23d2f7 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
diff --git a/services/app/pom.xml b/services/app/pom.xml
index 23fab194cb..b12530d8fc 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 9d2c917dcc..6ebeaa0694 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 924a4cfc0c..23c4ecba32 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 a717f6452e..c5367664a4 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 dd1018b225..191b744d64 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 a6c06555c1..f11ebc10c9 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 30c00ab1df..8662c30bd7 100644
--- a/services/submission/pom.xml
+++ b/services/submission/pom.xml
@@ -15,7 +15,7 @@
- 1.0.3
+ 1.0.4
backend-submission