From 236f1bc4eda81fad128874d9b4307a23bf30d5ac Mon Sep 17 00:00:00 2001 From: hide_d Date: Fri, 23 Oct 2026 00:18:45 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=95=EC=8B=9D=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EC=9D=98=20=EC=98=A4=ED=94=88=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EA=B3=BC=20=EB=8F=99=EC=9D=98=20=EA=B8=B0=EB=B0=98=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=EC=9D=84=20=EC=98=81=EC=86=8D=ED=99=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/gateway-api/src/adminCapabilities.ts | 2 + app/gateway-api/src/adminRouter.ts | 112 ++- app/gateway-api/src/openRequests/policy.ts | 38 + app/gateway-api/src/openRequests/router.ts | 48 ++ app/gateway-api/src/openRequests/service.ts | 392 ++++++++++ .../src/orchestrator/gatewayOrchestrator.ts | 44 +- .../src/orchestrator/profileReleaseSource.ts | 4 +- app/gateway-api/src/resetOptions.ts | 68 ++ app/gateway-api/src/router.ts | 2 + .../test/openRequests.integration.test.ts | 569 +++++++++++++++ .../test/orchestratorOperations.test.ts | 69 ++ app/gateway-api/test/releaseManifest.test.ts | 2 +- .../e2e/open-suggestion.spec.ts | 139 +++- app/gateway-frontend/src/views/LobbyView.vue | 4 +- .../src/views/OpenSuggestionView.vue | 670 ++++++++++++++++-- docs/gateway-open-requests.md | 131 ++++ docs/release-operations.md | 3 + packages/infra/package.json | 1 + .../migration.sql | 28 + packages/infra/prisma/gateway.prisma | 41 ++ .../scripts/verify-open-request-migration.mjs | 104 +++ release-manifest.json | 2 +- tools/conditional-integration-registry.tsv | 1 + tools/run-conditional-integration.sh | 1 + 24 files changed, 2333 insertions(+), 142 deletions(-) create mode 100644 app/gateway-api/src/openRequests/policy.ts create mode 100644 app/gateway-api/src/openRequests/router.ts create mode 100644 app/gateway-api/src/openRequests/service.ts create mode 100644 app/gateway-api/src/resetOptions.ts create mode 100644 app/gateway-api/test/openRequests.integration.test.ts create mode 100644 docs/gateway-open-requests.md create mode 100644 packages/infra/prisma/gateway-migrations/20261022000000_add_open_requests/migration.sql create mode 100644 packages/infra/scripts/verify-open-request-migration.mjs diff --git a/app/gateway-api/src/adminCapabilities.ts b/app/gateway-api/src/adminCapabilities.ts index f04ef990..05580e66 100644 --- a/app/gateway-api/src/adminCapabilities.ts +++ b/app/gateway-api/src/adminCapabilities.ts @@ -131,6 +131,8 @@ export const getAdminCapability = (permission: string): AdminCapabilityDefinitio CAPABILITY_BY_PERMISSION.get(permission); export const resolveAdminActionCapability = (path: string, rawInput?: unknown): string | undefined => { + if (path.endsWith('.openRequests.decide')) return 'admin.scenarios.reset'; + if (path.endsWith('.openRequests.updatePolicy')) return 'admin.profiles.settings'; if (path.endsWith('.users.createLocal')) return 'admin.users.create'; if (path.includes('.users.')) return 'admin.users.manage'; if (path.includes('.system.')) return 'admin.notice.manage'; diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index b78cc444..e2ed8dea 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -7,6 +7,16 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common'; import type { GatewayPrisma } from '@sammo-ts/infra'; import { procedure, router } from './trpc.js'; +import { zOpenRequestPolicy } from './openRequests/policy.js'; +import { actOnOpenRequest, updateOpenRequestPolicy } from './openRequests/service.js'; +import { + isAllowedTurnTerm, + zInstallOptions, + zOperationInstallOptions, + zProfileResetDefaults, + buildResetOtherTextInfo, + SYSTEM_PROFILE_RESET_DEFAULTS, +} from './resetOptions.js'; import { listScenarioPreviews, resolveGitBranchCommitSha, @@ -51,7 +61,6 @@ const zSpecialAccessProfile = z .min(1) .max(64) .regex(/^[a-z0-9_-]+(?::[a-zA-Z0-9._-]+)?$/); -const zJoinMode = z.enum(['full', 'onlyRandom']); const zServerAction = z.enum([ 'RESUME', 'PAUSE', @@ -441,15 +450,6 @@ const zLocalAccountInput = z.object({ displayName: z.string().min(2).max(40).optional(), }); -const zInstallAutorun = z.object({ - limitMinutes: z.number().int().min(0).max(43200), - options: z.array(z.enum(AUTORUN_USER_OPTIONS)), -}); - -// Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes -// every positive divisor of 120 valid, including 3 and 8 minutes. -const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0; - const zRuntimeSettings = z .object({ turnTermMinutes: z @@ -479,60 +479,6 @@ const isUniqueConstraintError = (error: unknown): boolean => const isProfileOperationConflictError = (error: unknown): boolean => error instanceof GatewayProfileOperationConflictError || isUniqueConstraintError(error); -const zInstallOptions = z.object({ - scenarioId: z.number().int().min(0), - turnTermMinutes: z - .number() - .int() - .refine((value) => isAllowedTurnTerm(value), { - message: '턴 시간은 120의 약수여야 합니다.', - }), - sync: z.boolean(), - fiction: z.number().int().min(0).max(1), - extend: z.boolean(), - blockGeneralCreate: z.number().int().min(0).max(2), - npcMode: z.number().int().min(0).max(2), - showImgLevel: z.number().int().min(0).max(3), - tournamentTrig: z.boolean(), - joinMode: zJoinMode, - autorunUser: zInstallAutorun.nullable().optional(), - openAt: z.string().datetime().optional(), - preopenAt: z.string().datetime().optional(), - gitRef: z.string().min(1).max(128).optional(), -}); -const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true }); -const zProfileResetDefaults = zInstallOptions.omit({ - scenarioId: true, - openAt: true, - preopenAt: true, - gitRef: true, -}); -const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer = { - turnTermMinutes: 60, - sync: true, - fiction: 1, - extend: true, - blockGeneralCreate: 0, - npcMode: 0, - showImgLevel: 3, - tournamentTrig: true, - joinMode: 'full', - autorunUser: null, -}; - -const buildResetOtherTextInfo = (install: z.infer): string => { - const settings: string[] = []; - if (!install.sync) settings.push('시간동기화 없음'); - if (!install.extend) settings.push('확장 NPC 미포함'); - if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가'); - if (install.blockGeneralCreate === 2) settings.push('장수명 무작위'); - if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관'); - if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) { - settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시'); - } - if (!install.tournamentTrig) settings.push('토너먼트 수동 시작'); - return settings.join(', '); -}; const zSourceMode = z.enum(['BRANCH', 'COMMIT']); const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']); @@ -631,6 +577,44 @@ const applyMetaPatch = ( }; export const adminRouter = router({ + openRequests: router({ + permissions: adminProcedure + .input(z.object({ profileName: z.string().min(1).max(64) })) + .query(({ ctx, input }) => ({ + canAccept: hasScopedPermission(requireAdminAuth(ctx), ROLE_ADMIN_SCENARIO_RESET, input.profileName), + canManagePolicy: hasScopedPermission( + requireAdminAuth(ctx), + ROLE_ADMIN_PROFILE_SETTINGS, + input.profileName + ), + })), + decide: adminProcedure + .input(z.object({ id: z.string().uuid(), action: z.enum(['ACCEPT', 'REJECT']) }).strict()) + .mutation(async ({ ctx, input }) => { + const request = await ctx.prisma.gatewayOpenRequest.findUnique({ + where: { id: input.id }, + select: { profileName: true }, + }); + if (!request) throw new TRPCError({ code: 'NOT_FOUND', message: '오픈 요청을 찾을 수 없습니다.' }); + const auth = requireAdminAuth(ctx); + assertPermission(auth, ROLE_ADMIN_SCENARIO_RESET, request.profileName); + return actOnOpenRequest(ctx, auth.user, input.id, input.action); + }), + updatePolicy: adminProcedure + .input( + z + .object({ + profileName: z.string().min(1).max(64), + policy: zOpenRequestPolicy, + reason: z.string().trim().min(3).max(200), + }) + .strict() + ) + .mutation(async ({ ctx, input }) => { + assertPermission(requireAdminAuth(ctx), ROLE_ADMIN_PROFILE_SETTINGS, input.profileName); + return updateOpenRequestPolicy(ctx, input.profileName, input.policy, requireAdminAuth(ctx).user.id); + }), + }), capabilities: router({ list: adminProcedure.query(({ ctx }) => { const adminAuth = requireAdminAuth(ctx); diff --git a/app/gateway-api/src/openRequests/policy.ts b/app/gateway-api/src/openRequests/policy.ts new file mode 100644 index 00000000..eb728e4d --- /dev/null +++ b/app/gateway-api/src/openRequests/policy.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +export const zOpenRequestPolicy = z + .object({ + enabled: z.boolean().default(true), + lifetimeDays: z.number().int().min(1).max(365).default(7), + buildLeadMinutes: z.number().int().min(1).max(10080).default(10), + // null means opener approval only, including CHE by default. + endorsementThreshold: z.number().int().min(1).max(10000).nullable().default(null), + }) + .strict(); + +export interface OpenRequestPolicy { + enabled: boolean; + lifetimeDays: number; + buildLeadMinutes: number; + endorsementThreshold: number | null; +} + +export const readOpenRequestPolicy = (meta: Record): OpenRequestPolicy => + zOpenRequestPolicy.parse(meta.openRequestPolicy ?? {}); + +export const canRequestOpening = (status: string): boolean => ['COMPLETED', 'STOPPED', 'CANCELLED'].includes(status); + +export const openingSeasonKey = (profile: { currentScenario: string | null; openAt?: string | Date | null }): string => + JSON.stringify([profile.currentScenario, profile.openAt ? new Date(profile.openAt).toISOString() : null]); + +export const canExecuteOpeningReservation = ( + profile: { status: string; currentScenario: string | null; openAt?: string | Date | null }, + seasonKey: unknown, + openAt: unknown, + now: Date +): boolean => + canRequestOpening(profile.status) && + openingSeasonKey(profile) === seasonKey && + typeof openAt === 'string' && + Number.isFinite(Date.parse(openAt)) && + Date.parse(openAt) > now.getTime(); diff --git a/app/gateway-api/src/openRequests/router.ts b/app/gateway-api/src/openRequests/router.ts new file mode 100644 index 00000000..b127770c --- /dev/null +++ b/app/gateway-api/src/openRequests/router.ts @@ -0,0 +1,48 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { procedure, router } from '../trpc.js'; +import { isGatewaySessionCurrent } from '../auth/sessionValidity.js'; +import type { GatewayApiContext } from '../context.js'; +import { actOnOpenRequest, createOpenRequest, listOpenRequests, zOpeningInstall } from './service.js'; + +const memberProcedure = procedure.use(async ({ ctx, next }) => { + const header = ctx.requestHeaders['x-session-token']; + const token = Array.isArray(header) ? header[0] : header; + const session = token ? await ctx.sessions.getSession(token) : null; + const user = session ? await ctx.users.findById(session.userId) : null; + if (!session || !user || !isGatewaySessionCurrent(session, user)) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' }); + } + return next({ ctx: { ...ctx, openingUser: user } }); +}); +const visibleProfile = async (ctx: GatewayApiContext, userId: string, profileName: string): Promise => { + const profiles = await ctx.profileStatus.listLobbyProfiles({ userId }); + if (!profiles.some((profile) => profile.profileName === profileName)) + throw new TRPCError({ code: 'NOT_FOUND', message: '서버를 찾을 수 없습니다.' }); +}; + +export const openRequestsRouter = router({ + list: memberProcedure + .input(z.object({ profileName: z.string().min(1).max(64) }).strict()) + .query(async ({ ctx, input }) => { + await visibleProfile(ctx, ctx.openingUser.id, input.profileName); + return listOpenRequests(ctx, ctx.openingUser, input.profileName); + }), + create: memberProcedure + .input(z.object({ profileName: z.string().min(1).max(64), install: zOpeningInstall }).strict()) + .mutation(async ({ ctx, input }) => { + await visibleProfile(ctx, ctx.openingUser.id, input.profileName); + return createOpenRequest(ctx, ctx.openingUser, input.profileName, input.install); + }), + act: memberProcedure + .input(z.object({ id: z.string().uuid(), action: z.enum(['ENDORSE', 'WITHDRAW']) }).strict()) + .mutation(async ({ ctx, input }) => { + const request = await ctx.prisma.gatewayOpenRequest.findUnique({ + where: { id: input.id }, + select: { profileName: true }, + }); + if (!request) throw new TRPCError({ code: 'NOT_FOUND', message: '요청을 찾을 수 없습니다.' }); + await visibleProfile(ctx, ctx.openingUser.id, request.profileName); + return actOnOpenRequest(ctx, ctx.openingUser, input.id, input.action); + }), +}); diff --git a/app/gateway-api/src/openRequests/service.ts b/app/gateway-api/src/openRequests/service.ts new file mode 100644 index 00000000..2ea4215a --- /dev/null +++ b/app/gateway-api/src/openRequests/service.ts @@ -0,0 +1,392 @@ +import { isDeepStrictEqual } from 'node:util'; +import { TRPCError } from '@trpc/server'; +import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions'; +import type { GatewayPrisma, GatewayPrismaClient } from '@sammo-ts/infra'; +import type { GatewayApiContext } from '../context.js'; +import type { UserRecord } from '../auth/userRepository.js'; +import { resolveLocalAccountProfilePolicy } from '../auth/localAccountPolicy.js'; +import { CONTROL_PLANE_OPERATION_CLAIM_LOCK } from '../orchestrator/profileRepository.js'; +import { readProfileReleaseSource } from '../orchestrator/profileReleaseSource.js'; +import { buildResetOtherTextInfo, zOperationInstallOptions } from '../resetOptions.js'; +import { + listScenarioPreviews, + resolveGitBranchCommitSha, + resolveGitCommitSha, + supportsProfileReleaseBuild, +} from '../scenario/scenarioCatalog.js'; +import { canRequestOpening, openingSeasonKey, readOpenRequestPolicy, type OpenRequestPolicy } from './policy.js'; +import { z } from 'zod'; + +export const zOpeningInstall = zOperationInstallOptions.required({ preopenAt: true, openAt: true }).strict(); +export type OpeningInstall = z.infer; +export interface OpenRequestResult { + id: string; + status: string; +} +export interface OpenRequestList { + policy: OpenRequestPolicy; + canRequest: boolean; + requests: Array<{ + id: string; + status: string; + requesterName: string; + isMine: boolean; + announcement: { scenarioTitle: string; defaultStatTotal: number }; + install: OpeningInstall; + expiresAt: string; + createdAt: string; + endorsementCount: number; + endorsed: boolean; + canEndorse: boolean; + operationStatus: string | null; + }>; +} +type Profile = NonNullable>>; +type Request = NonNullable>>; +const fail = (message: string, code: 'BAD_REQUEST' | 'CONFLICT' | 'FORBIDDEN' | 'NOT_FOUND' = 'BAD_REQUEST'): never => { + throw new TRPCError({ code, message }); +}; + +// Authentication grace is deliberately not full membership. Special grants must apply to this profile. +export const isOpeningMember = async ( + ctx: GatewayApiContext, + user: UserRecord, + profile: Profile, + now: Date +): Promise => { + if (user.deleteAfter || isGameAccessBlocked(user.sanctions, [profile.profile, profile.profileName], now)) + return false; + if (user.oauthType === 'KAKAO' && user.oauthId?.trim() && user.kakaoVerifiedAt) return true; + const policy = resolveLocalAccountProfilePolicy({ + profile: profile.profile, + profileName: profile.profileName, + profileMeta: profile.meta as Record, + defaultGraceDays: ctx.localAccountGraceDays, + user, + specialAccessGrants: await ctx.users.listSpecialAccessGrants(user.id), + now, + }); + return policy.kakaoVerified || policy.specialAccess !== null; +}; + +const assertMember = async (ctx: GatewayApiContext, user: UserRecord, profile: Profile, now: Date): Promise => { + const currentUser = await ctx.users.findById(user.id); + if (!currentUser || !(await isOpeningMember(ctx, currentUser, profile, now))) + fail('카카오 인증 또는 이 서버의 정식 접근 허용이 필요합니다.', 'FORBIDDEN'); +}; + +const buildAt = (preopenAt: Date, lead: number): Date => new Date(preopenAt.getTime() - lead * 60_000); +const activeStatus = (request: Request, profile: Profile, now: Date): string => { + if (request.status !== 'PENDING') return request.status; + if (!canRequestOpening(profile.status) || request.seasonKey !== openingSeasonKey(profile)) return 'SUPERSEDED'; + const policy = readOpenRequestPolicy(profile.meta as Record); + if (request.expiresAt <= now || buildAt(request.preopenAt, policy.buildLeadMinutes) <= now) return 'EXPIRED'; + return request.status; +}; + +export const listOpenRequests = async ( + ctx: GatewayApiContext, + user: UserRecord, + profileName: string +): Promise => { + const profile = await ctx.prisma.gatewayProfile.findUnique({ where: { profileName } }); + if (!profile) return fail('서버를 찾을 수 없습니다.', 'NOT_FOUND'); + const now = new Date(); + const policy = readOpenRequestPolicy(profile.meta as Record); + const member = await isOpeningMember(ctx, user, profile, now); + const requests = await ctx.prisma.gatewayOpenRequest.findMany({ + where: { + profileName, + OR: [ + { status: 'PENDING', expiresAt: { gt: now } }, + { status: 'RESERVED', openAt: { gt: now } }, + ], + }, + orderBy: [{ createdAt: 'desc' }, { id: 'asc' }], + take: 100, + include: { + requester: { select: { displayName: true } }, + endorsements: { select: { userId: true } }, + operation: { select: { status: true } }, + }, + }); + const voters = await ctx.users.findByIds([ + ...new Set(requests.flatMap((request) => request.endorsements.map((vote) => vote.userId))), + ]); + const qualifiedVoters = new Set(); + for (const voter of voters) { + if (await isOpeningMember(ctx, voter, profile, now)) qualifiedVoters.add(voter.id); + } + const activeOperation = await ctx.prisma.gatewayOperation.findFirst({ + where: { profileName, status: { in: ['QUEUED', 'RUNNING'] } }, + select: { id: true }, + }); + return { + policy, + canRequest: member && policy.enabled && canRequestOpening(profile.status) && !activeOperation, + requests: requests + .filter((request) => ['PENDING', 'RESERVED'].includes(activeStatus(request, profile, now))) + .map((request) => ({ + id: request.id, + status: request.status, + requesterName: request.requester.displayName, + isMine: request.requestedBy === user.id, + announcement: z + .object({ scenarioTitle: z.string(), defaultStatTotal: z.number() }) + .parse(request.announcement), + install: zOpeningInstall.parse(request.install), + expiresAt: request.expiresAt.toISOString(), + createdAt: request.createdAt.toISOString(), + endorsementCount: request.endorsements.filter((vote) => qualifiedVoters.has(vote.userId)).length, + endorsed: request.endorsements.some((vote) => vote.userId === user.id), + canEndorse: + member && + policy.enabled && + policy.endorsementThreshold !== null && + request.requestedBy !== user.id && + request.status === 'PENDING', + operationStatus: request.operation?.status ?? null, + })), + }; +}; + +const inProfileTransaction = async ( + ctx: GatewayApiContext, + profileName: string, + run: (tx: GatewayPrisma.TransactionClient, profile: Profile, now: Date) => Promise +): Promise => + ctx.prisma.$transaction( + async (tx) => { + // Same ordering as RESET admission/claim. This serializes consensus with operator operations. + await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${CONTROL_PLANE_OPERATION_CLAIM_LOCK}, 0))::text`; + await tx.$queryRaw`SELECT "profile_name" FROM "gateway_profile" WHERE "profile_name" = ${profileName} FOR UPDATE`; + const profile = await tx.gatewayProfile.findUnique({ where: { profileName } }); + if (!profile) return fail('서버를 찾을 수 없습니다.', 'NOT_FOUND'); + const [{ now }] = await tx.$queryRaw>`SELECT clock_timestamp() AS now`; + return run(tx, profile, now!); + }, + { timeout: 60_000 } + ); + +export const createOpenRequest = async ( + ctx: GatewayApiContext, + user: UserRecord, + profileName: string, + install: OpeningInstall +): Promise => + inProfileTransaction(ctx, profileName, async (tx, profile, now) => { + await assertMember(ctx, user, profile, now); + const policy = readOpenRequestPolicy(profile.meta as Record); + if (!policy.enabled || !canRequestOpening(profile.status)) + return fail('통일되었거나 닫힌 서버에만 오픈 요청을 할 수 있습니다.', 'CONFLICT'); + const preopenAt = new Date(install.preopenAt), + openAt = new Date(install.openAt); + const deadline = buildAt(preopenAt, policy.buildLeadMinutes); + if (deadline <= now || preopenAt >= openAt) + return fail('가오픈은 빌드 선행 시간 이후, 정식 오픈은 가오픈 이후여야 합니다.'); + if (install.autorunUser && install.autorunUser.limitMinutes > 0 !== install.autorunUser.options.length > 0) + return fail('자율행동 시간과 항목을 함께 지정해 주세요.'); + if (await tx.gatewayOperation.findFirst({ where: { profileName, status: { in: ['QUEUED', 'RUNNING'] } } })) + return fail('진행 중이거나 예약된 서버 작업이 있습니다.', 'CONFLICT'); + const old = await tx.gatewayOpenRequest.findMany({ where: { profileName, status: 'PENDING' } }); + for (const request of old) { + const status = activeStatus(request, profile, now); + if (status !== 'PENDING') + await tx.gatewayOpenRequest.update({ where: { id: request.id }, data: { status } }); + else if (request.requestedBy === user.id) { + if (isDeepStrictEqual(request.install, install)) return { id: request.id, status: request.status }; + return fail('기존 요청을 철회한 뒤 새 요청을 등록해 주세요.', 'CONFLICT'); + } + } + if (old.filter((request) => activeStatus(request, profile, now) === 'PENDING').length >= 100) + return fail('이 서버의 대기 요청이 너무 많습니다.', 'CONFLICT'); + if (!profile.buildCommitSha) return fail('서버의 시나리오 버전이 준비되지 않았습니다.'); + const scenario = (await listScenarioPreviews({ gitRef: profile.buildCommitSha })).find( + (entry) => entry.id === install.scenarioId + ); + if (!scenario) return fail('현재 서버에 없는 시나리오입니다.'); + const expiresAt = new Date(Math.min(now.getTime() + policy.lifetimeDays * 86_400_000, deadline.getTime())); + const request = await tx.gatewayOpenRequest.create({ + data: { + profileName, + requestedBy: user.id, + install, + preopenAt, + openAt, + expiresAt, + seasonKey: openingSeasonKey(profile), + announcement: { scenarioTitle: scenario.title, defaultStatTotal: scenario.defaultStatTotal }, + }, + }); + return { id: request.id, status: request.status }; + }); + +// Called only while holding the shared admission lock. All mutations, including the winning +// endorsement, sibling supersession, operation, and log either commit together or roll back. +const reserve = async ( + ctx: GatewayApiContext, + tx: GatewayPrisma.TransactionClient, + profile: Profile, + request: Request, + actorId: string, + now: Date +) => { + const requester = await ctx.users.findById(request.requestedBy); + if (!requester || !(await isOpeningMember(ctx, requester, profile, now))) + return fail('요청자의 정식 참여 자격을 확인해 주세요.', 'CONFLICT'); + if ( + await tx.gatewayOperation.findFirst({ + where: { profileName: profile.profileName, status: { in: ['QUEUED', 'RUNNING'] } }, + }) + ) + return fail('진행 중이거나 예약된 서버 작업이 있습니다.', 'CONFLICT'); + const policy = readOpenRequestPolicy(profile.meta as Record); + const install = zOpeningInstall.parse(request.install); + const source = readProfileReleaseSource({ + meta: profile.meta as GatewayPrisma.JsonObject, + buildCommitSha: profile.buildCommitSha ?? undefined, + }); + if (!source) return fail('서버 패치 정책이 준비되지 않았습니다.'); + const commit = + source.mode === 'BRANCH' ? await resolveGitBranchCommitSha(source.ref) : await resolveGitCommitSha(source.ref); + const scenario = (await listScenarioPreviews({ gitRef: commit })).find((entry) => entry.id === install.scenarioId); + if (!scenario || !(await supportsProfileReleaseBuild(commit))) + return fail('패치 정책의 버전에서 시나리오 또는 배포 빌드를 사용할 수 없습니다.'); + const scheduledAt = buildAt(request.preopenAt, policy.buildLeadMinutes); + const [{ now: admissionNow }] = await tx.$queryRaw>`SELECT clock_timestamp() AS now`; + if (scheduledAt <= admissionNow! || request.expiresAt <= admissionNow!) + return fail('빌드 시작 시각이 지나 요청을 수락할 수 없습니다.', 'CONFLICT'); + const operation = await tx.gatewayOperation.create({ + data: { + profileName: profile.profileName, + type: 'RESET', + sourceMode: source.mode, + sourceRef: source.mode === 'COMMIT' ? commit : source.ref, + requestedBy: actorId, + scheduledAt, + payload: { + install, + requestedSource: 'CURRENT', + releaseSource: { ...source }, + openRequestId: request.id, + openRequestSeasonKey: request.seasonKey, + publicAnnouncement: { + enabled: true, + scenarioId: scenario.id, + scenarioTitle: scenario.title, + defaultStatTotal: scenario.defaultStatTotal, + scheduledAt: scheduledAt.toISOString(), + preopenAt: install.preopenAt, + openAt: install.openAt, + turnTermMinutes: install.turnTermMinutes, + fictionMode: install.fiction === 1 ? '가상' : '사실', + npcMode: install.npcMode, + otherTextInfo: buildResetOtherTextInfo(install), + autorunUser: install.autorunUser ?? null, + }, + }, + reason: '사용자 오픈 요청 수락', + }, + }); + await tx.gatewayOperationLog.create({ + data: { + operationId: operation.id, + level: 'INFO', + phase: 'queue', + message: '오픈 요청이 서버 패치 정책을 따르는 초기화 예약으로 전환되었습니다.', + }, + }); + await tx.gatewayOpenRequest.update({ + where: { id: request.id }, + data: { status: 'RESERVED', operationId: operation.id, decidedBy: actorId, decidedAt: now }, + }); + await tx.gatewayOpenRequest.updateMany({ + where: { profileName: profile.profileName, status: 'PENDING', id: { not: request.id } }, + data: { status: 'SUPERSEDED', decidedAt: now }, + }); + return { id: request.id, status: 'RESERVED' }; +}; + +export const actOnOpenRequest = async ( + ctx: GatewayApiContext, + user: UserRecord, + id: string, + action: 'ENDORSE' | 'WITHDRAW' | 'ACCEPT' | 'REJECT' +): Promise => { + const initial = await ctx.prisma.gatewayOpenRequest.findUnique({ where: { id } }); + if (!initial) return fail('오픈 요청을 찾을 수 없습니다.', 'NOT_FOUND'); + return inProfileTransaction(ctx, initial.profileName, async (tx, profile, now) => { + const request = await tx.gatewayOpenRequest.findUniqueOrThrow({ where: { id } }); + if (action === 'ENDORSE') await assertMember(ctx, user, profile, now); + if (action === 'WITHDRAW' && request.requestedBy !== user.id) + return fail('자신의 요청만 철회할 수 있습니다.', 'FORBIDDEN'); + if (request.status === 'RESERVED' && ['ACCEPT', 'ENDORSE'].includes(action)) + return { id, status: request.status }; + if (action === 'WITHDRAW' && request.status === 'WITHDRAWN') return { id, status: request.status }; + if (activeStatus(request, profile, now) !== 'PENDING') + return fail('만료되었거나 이미 처리된 요청입니다.', 'CONFLICT'); + if (action === 'WITHDRAW' || action === 'REJECT') { + const status = action === 'WITHDRAW' ? 'WITHDRAWN' : 'REJECTED'; + await tx.gatewayOpenRequest.update({ where: { id }, data: { status, decidedBy: user.id, decidedAt: now } }); + return { id, status }; + } + const policy = readOpenRequestPolicy(profile.meta as Record); + if (!policy.enabled) return fail('이 서버는 오픈 요청을 받지 않습니다.', 'CONFLICT'); + if (action === 'ACCEPT') return reserve(ctx, tx, profile, request, user.id, now); + if (request.requestedBy === user.id) return fail('요청자는 자신의 요청에 동의할 수 없습니다.'); + if (policy.endorsementThreshold === null) return fail('이 서버는 오프너 수락으로만 예약할 수 있습니다.'); + await tx.gatewayOpenEndorsement.upsert({ + where: { requestId_userId: { requestId: id, userId: user.id } }, + create: { requestId: id, userId: user.id }, + update: {}, + }); + const votes = await tx.gatewayOpenEndorsement.findMany({ where: { requestId: id } }); + if (votes.length >= policy.endorsementThreshold) { + // Revoked, banned, deleted or unverified voters no longer contribute to automatic approval. + let valid = 0; + for (const voter of await ctx.users.findByIds(votes.map((vote) => vote.userId))) { + if (await isOpeningMember(ctx, voter, profile, now)) valid++; + } + if (valid >= policy.endorsementThreshold) return reserve(ctx, tx, profile, request, user.id, now); + } + return { id, status: 'PENDING' }; + }); +}; + +// Lowering the threshold also evaluates accumulated agreements immediately. Older eligible +// proposals win deterministically; the same admission lock prevents a parallel endorsement winner. +export const updateOpenRequestPolicy = async ( + ctx: GatewayApiContext, + profileName: string, + policy: OpenRequestPolicy, + actorId: string +): Promise => + inProfileTransaction(ctx, profileName, async (tx, profile, now) => { + const updated = await tx.gatewayProfile.update({ + where: { profileName }, + data: { meta: { ...(profile.meta as GatewayPrisma.JsonObject), openRequestPolicy: { ...policy } } }, + }); + if (policy.enabled && policy.endorsementThreshold !== null && canRequestOpening(updated.status)) { + const pending = await tx.gatewayOpenRequest.findMany({ + where: { profileName, status: 'PENDING' }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + include: { endorsements: { select: { userId: true } } }, + }); + for (const request of pending) { + if ( + activeStatus(request, updated, now) !== 'PENDING' || + request.endorsements.length < policy.endorsementThreshold + ) + continue; + let valid = 0; + for (const voter of await ctx.users.findByIds(request.endorsements.map((vote) => vote.userId))) { + if (await isOpeningMember(ctx, voter, updated, now)) valid++; + } + if (valid >= policy.endorsementThreshold) { + await reserve(ctx, tx, updated, request, actorId, now); + break; + } + } + } + return policy; + }); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 96ae60e6..99bacbbc 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -1,3 +1,4 @@ +import { canExecuteOpeningReservation } from '../openRequests/policy.js'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -1854,6 +1855,22 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { return; } + if (operation.type === 'RESET' && typeof operation.payload.openRequestId === 'string') { + const install = isRecord(operation.payload.install) ? operation.payload.install : {}; + if ( + !canExecuteOpeningReservation( + profile, + operation.payload.openRequestSeasonKey, + install.openAt, + this.now() + ) + ) { + throw new Error( + '오픈 요청 이후 서버가 다시 열렸거나 기수가 변경되었거나 오픈 시각이 지나 예약을 실행할 수 없습니다.' + ); + } + } + if (!operation.sourceMode || !operation.sourceRef) { throw new Error('Operation source mode and ref are required.'); } @@ -1958,7 +1975,10 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { commitSha, assertLease, operation.id, - readOperationReleaseSource(operation) + readOperationReleaseSource(operation), + typeof payload.openRequestId === 'string' + ? { seasonKey: payload.openRequestSeasonKey, openAt: install.openAt } + : undefined ); if (result.status === 'REQUESTED') { const retryAt = new Date(this.now().getTime() + this.adminActionIntervalMs).toISOString(); @@ -2413,7 +2433,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { commitShaOverride?: string, assertLease?: () => Promise, operationId?: string, - releaseSource?: ProfileReleaseSource + releaseSource?: ProfileReleaseSource, + communityReservation?: { seasonKey: unknown; openAt: unknown } ): Promise { const appendLog = async ( phase: string, @@ -2525,6 +2546,24 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ); return { status: 'FAILED', detail: 'selected workspace build failed' }; } + const assertCommunityReservation = async (): Promise => { + if (!communityReservation) return; + const current = await this.repository.getProfile(profile.profileName); + if ( + !current || + !canExecuteOpeningReservation( + current, + communityReservation.seasonKey, + communityReservation.openAt, + this.now() + ) + ) { + throw new Error( + '오픈 요청 이후 서버 상태·기수가 변경되었거나 오픈 시각이 지나 초기화를 중단했습니다.' + ); + } + }; + await assertCommunityReservation(); await appendLog('seed', '선택 버전의 profile seed CLI를 확인합니다.'); await this.assertProfileSeedCli(workspace.root); // A newly provisioned profile schema has no world_state row (or table) yet. @@ -2568,6 +2607,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const firstGameIdx = resolveProfileFirstGameIdx(profileMeta); const baseSeason = readMetaNumber(normalizeMeta(seedInfo.meta), 'season'); const season = nextSeasonIdx ?? baseSeason ?? 1; + await assertCommunityReservation(); await updateClaimedProfile({ status: 'STOPPED' }, () => this.repository.updateStatus(profile.profileName, 'STOPPED') ); diff --git a/app/gateway-api/src/orchestrator/profileReleaseSource.ts b/app/gateway-api/src/orchestrator/profileReleaseSource.ts index 4f956cd7..41b83f14 100644 --- a/app/gateway-api/src/orchestrator/profileReleaseSource.ts +++ b/app/gateway-api/src/orchestrator/profileReleaseSource.ts @@ -13,7 +13,9 @@ export interface ProfileReleaseSource { const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === 'object' && !Array.isArray(value)); -export const readProfileReleaseSource = (profile: GatewayProfileRecord): ProfileReleaseSource | null => { +export const readProfileReleaseSource = ( + profile: Pick +): ProfileReleaseSource | null => { const stored = isRecord(profile.meta) && isRecord(profile.meta.releaseSource) ? profile.meta.releaseSource : null; const mode = stored?.mode; const ref = typeof stored?.ref === 'string' ? stored.ref.trim() : ''; diff --git a/app/gateway-api/src/resetOptions.ts b/app/gateway-api/src/resetOptions.ts new file mode 100644 index 00000000..b90cb2b1 --- /dev/null +++ b/app/gateway-api/src/resetOptions.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +const AUTORUN_USER_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const; +const zJoinMode = z.enum(['full', 'onlyRandom']); + +const zInstallAutorun = z.object({ + limitMinutes: z.number().int().min(0).max(43200), + options: z.array(z.enum(AUTORUN_USER_OPTIONS)), +}); + +// Ref aligns a 12-month game year inside a 24-hour wall-clock day. That makes +// every positive divisor of 120 valid, including 3 and 8 minutes. +export const isAllowedTurnTerm = (value: number): boolean => Number.isInteger(value) && value > 0 && 120 % value === 0; + +export const zInstallOptions = z.object({ + scenarioId: z.number().int().min(0), + turnTermMinutes: z + .number() + .int() + .refine((value) => isAllowedTurnTerm(value), { + message: '턴 시간은 120의 약수여야 합니다.', + }), + sync: z.boolean(), + fiction: z.number().int().min(0).max(1), + extend: z.boolean(), + blockGeneralCreate: z.number().int().min(0).max(2), + npcMode: z.number().int().min(0).max(2), + showImgLevel: z.number().int().min(0).max(3), + tournamentTrig: z.boolean(), + joinMode: zJoinMode, + autorunUser: zInstallAutorun.nullable().optional(), + openAt: z.string().datetime().optional(), + preopenAt: z.string().datetime().optional(), + gitRef: z.string().min(1).max(128).optional(), +}); +export const zOperationInstallOptions = zInstallOptions.omit({ gitRef: true }); +export const zProfileResetDefaults = zInstallOptions.omit({ + scenarioId: true, + openAt: true, + preopenAt: true, + gitRef: true, +}); +export const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer = { + turnTermMinutes: 60, + sync: true, + fiction: 1, + extend: true, + blockGeneralCreate: 0, + npcMode: 0, + showImgLevel: 3, + tournamentTrig: true, + joinMode: 'full', + autorunUser: null, +}; + +export const buildResetOtherTextInfo = (install: z.infer): string => { + const settings: string[] = []; + if (!install.sync) settings.push('시간동기화 없음'); + if (!install.extend) settings.push('확장 NPC 미포함'); + if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가'); + if (install.blockGeneralCreate === 2) settings.push('장수명 무작위'); + if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관'); + if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) { + settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시'); + } + if (!install.tournamentTrig) settings.push('토너먼트 수동 시작'); + return settings.join(', '); +}; diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index e082c9e8..1525a194 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -11,6 +11,7 @@ import { procedure, router } from './trpc.js'; import { toPublicUser } from './auth/userRepository.js'; import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js'; import { adminRouter } from './adminRouter.js'; +import { openRequestsRouter } from './openRequests/router.js'; import { accountRouter } from './account/router.js'; import { hasActiveSpecialAccountGrant, @@ -130,6 +131,7 @@ const finishKakaoLoginOrRequestPasswordSetup = async ctx.navigationConfig.get()), }), diff --git a/app/gateway-api/test/openRequests.integration.test.ts b/app/gateway-api/test/openRequests.integration.test.ts new file mode 100644 index 00000000..ae6f974f --- /dev/null +++ b/app/gateway-api/test/openRequests.integration.test.ts @@ -0,0 +1,569 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fastifyStatic from '@fastify/static'; +import { randomUUID } from 'node:crypto'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import fastify, { type FastifyRequest } from 'fastify'; +import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common'; +import { createGatewayPostgresConnector } from '@sammo-ts/infra'; +import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js'; +import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; +import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js'; +import { createGatewayApiContext } from '../src/context.js'; +import { createGatewayProfileRepository } from '../src/orchestrator/profileRepository.js'; +import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js'; +import { appRouter } from '../src/router.js'; +import { createOpenRequest, actOnOpenRequest, listOpenRequests } from '../src/openRequests/service.js'; +import { readOpenRequestPolicy } from '../src/openRequests/policy.js'; +import * as catalog from '../src/scenario/scenarioCatalog.js'; + +vi.mock('../src/scenario/scenarioCatalog.js', () => ({ + listScenarioPreviews: vi.fn(async () => [ + { + id: 2, + title: '기본 시나리오', + defaultStatTotal: 165, + year: 184, + fiction: 1, + npcCount: 100, + npcExCount: 0, + npcNeutralCount: 0, + nations: [], + }, + ]), + resolveGitCommitSha: vi.fn(async () => 'a'.repeat(40)), + resolveGitBranchCommitSha: vi.fn(async () => 'b'.repeat(40)), + supportsProfileReleaseBuild: vi.fn(async () => true), +})); +const repositoryRoot = fileURLToPath(new URL('../../../', import.meta.url)); +const databaseUrl = process.env.OPEN_REQUEST_DATABASE_URL; +const suite = describe.runIf(Boolean(databaseUrl)); +const profileName = 'opening:default'; +const install = () => ({ + scenarioId: 2, + turnTermMinutes: 3, + sync: true, + fiction: 1, + extend: true, + blockGeneralCreate: 0, + npcMode: 0, + showImgLevel: 3, + tournamentTrig: true, + joinMode: 'full' as const, + autorunUser: null, + preopenAt: new Date(Date.now() + 86_400_000).toISOString(), + openAt: new Date(Date.now() + 90_000_000).toISOString(), +}); + +suite('durable opening requests (isolated PostgreSQL and HTTP)', () => { + const connector = createGatewayPostgresConnector({ url: databaseUrl ?? '' }); + const prisma = connector.prisma; + const users = createPostgresUserRepository(prisma); + const profiles = createGatewayProfileRepository(prisma); + const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 3600, gameSessionTtlSeconds: 600 }); + const ids = [randomUUID(), randomUUID(), randomUUID(), randomUUID(), randomUUID()]; + const passwordEnvelope = createPasswordEnvelopeService(); + const profileStatus = new InMemoryProfileStatusService([ + { + profileName, + profile: 'opening', + instanceKey: 'default', + currentScenario: null, + scenario: 'default', + status: 'STOPPED', + apiPort: 15991, + korName: '테스트', + color: '#fff', + runtime: { + apiRunning: false, + daemonRunning: false, + auctionRunning: false, + battleSimRunning: false, + tournamentRunning: false, + }, + lifecycle: { + runtimeExpected: false, + userAccessible: false, + turnsRunning: false, + operatorResumable: true, + dataInitialized: false, + }, + }, + ]); + const context = (headers: Record = {}) => + createGatewayApiContext({ + prisma, + users, + profiles, + sessions, + passwordEnvelope, + profileStatus, + requestHeaders: headers, + flushPublisher: { publishUserFlush: async () => {} }, + gameTokenSecret: 'fixture-only', + gameSessionTtlSeconds: 600, + publicBaseUrl: 'http://127.0.0.1', + localAccountGraceDays: 7, + adminLocalAccountEnabled: false, + localRegistrationEnabled: false, + kakaoClient: {} as never, + oauthSessions: {} as never, + orchestrator: {} as never, + }); + const user = async (index: number) => (await users.findById(ids[index]!))!; + const caller = async (index: number) => { + const session = await sessions.createSession(await user(index)); + return appRouter.createCaller(context({ 'x-session-token': session.sessionToken })); + }; + const app = fastify(); + beforeAll(async () => { + // This database is created exclusively for this suite by the verification runner. + if ( + !databaseUrl?.includes('open_request') && + new URL(databaseUrl!).searchParams.get('schema') !== process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA + ) + throw new Error('Use an isolated open_request database/schema.'); + await connector.connect(); + await app.register(fastifyTRPCPlugin, { + prefix: '/gateway/api/trpc', + trpcOptions: { + router: appRouter, + ...trpcJsonBodyHttpServerOptions, + createContext: ({ req }: { req: FastifyRequest }) => context(req.headers), + }, + }); + if (process.env.OPEN_REQUEST_BROWSER === '1') { + await app.register(fastifyStatic, { + root: path.join(repositoryRoot, 'app/gateway-frontend/dist'), + prefix: '/gateway/', + }); + app.get('/gateway/api/navigation', async () => + JSON.parse(await fs.readFile(path.join(repositoryRoot, 'resources/navigation.json'), 'utf8')) + ); + app.setNotFoundHandler((request, reply) => + request.url.startsWith('/gateway/') && !request.url.includes('/api/') + ? reply.sendFile('index.html') + : reply.code(404).send() + ); + } + await app.ready(); + }); + beforeEach(async () => { + await prisma.gatewayOpenRequest.deleteMany({ where: { profileName } }); + await prisma.gatewayOperation.deleteMany({ where: { profileName } }); + await prisma.specialAccountAccessGrant.deleteMany({ where: { userId: { in: ids } } }); + await prisma.appUser.deleteMany({ where: { id: { in: ids } } }); + for (let index = 0; index < ids.length; index++) { + await prisma.appUser.create({ + data: { + id: ids[index], + loginId: `opening-${ids[0]}-${index}`, + displayName: `사용자${index}-${ids[0]!.slice(0, 6)}`, + passwordHash: 'fixture', + passwordSalt: 'fixture', + oauthType: index === 4 ? 'NONE' : 'KAKAO', + oauthId: index === 4 ? null : `opening-${ids[0]}-${index}`, + kakaoVerifiedAt: index === 4 ? null : new Date(), + createdAt: new Date(2020 + index, 0, 1), + roles: index === 3 ? [`admin.scenarios.reset:${profileName}`] : [], + }, + }); + } + await profiles.upsertProfile({ + profile: 'opening', + instanceKey: 'default', + apiPort: 15991, + status: 'STOPPED', + buildCommitSha: 'a'.repeat(40), + meta: {}, + }); + await prisma.gatewayProfile.update({ + where: { profileName }, + data: { openAt: null, preopenAt: null, currentScenario: null, meta: {} }, + }); + vi.clearAllMocks(); + vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(true); + }); + afterAll(async () => { + await app.close(); + await connector.disconnect(); + }); + const create = async (index = 1) => createOpenRequest(context(), await user(index), profileName, install()); + const policy = async (threshold: number | null, extra: Record = {}) => + prisma.gatewayProfile.update({ + where: { profileName }, + data: { meta: { openRequestPolicy: { endorsementThreshold: threshold, ...extra } } }, + }); + + it('persists immutable settings and expires before build; repeated submission is idempotent', async () => { + const input = install(); + const first = await createOpenRequest(context(), await user(1), profileName, input); + const replay = await createOpenRequest(context(), await user(1), profileName, input); + expect(replay.id).toBe(first.id); + const row = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: first.id } }); + expect(row.install).toEqual(input); + expect(row.expiresAt.getTime()).toBe(new Date(input.preopenAt).getTime() - 600_000); + expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0); + expect((await listOpenRequests(context(), await user(2), profileName)).requests).toHaveLength(1); + expect(JSON.stringify(await listOpenRequests(context(), await user(2), profileName))).not.toContain('password'); + }); + it('uses the configured lifetime for distant proposals', async () => { + await policy(null, { lifetimeDays: 2 }); + const input = install(); + input.preopenAt = new Date(Date.now() + 10 * 86_400_000).toISOString(); + input.openAt = new Date(Date.now() + 11 * 86_400_000).toISOString(); + const r = await createOpenRequest(context(), await user(1), profileName, input); + const row = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } }); + expect(row.expiresAt.getTime() - row.createdAt.getTime()).toBeCloseTo(2 * 86_400_000, -3); + }); + it.each(['RUNNING', 'PREOPEN', 'PAUSED', 'RESERVED', 'DISABLED'] as const)( + 'rejects requests in %s', + async (status) => { + await prisma.gatewayProfile.update({ where: { profileName }, data: { status } }); + await expect(create()).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(await prisma.gatewayOpenRequest.count({ where: { profileName } })).toBe(0); + } + ); + it.each(['COMPLETED', 'STOPPED', 'CANCELLED'] as const)('accepts a request in %s', async (status) => { + await prisma.gatewayProfile.update({ where: { profileName }, data: { status } }); + await expect(create()).resolves.toMatchObject({ status: 'PENDING' }); + }); + it('rejects grace-only and sanctioned accounts, accepts a scoped active special grant', async () => { + await expect(create(4)).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await prisma.specialAccountAccessGrant.create({ + data: { + userId: ids[4]!, + kind: 'TESTER', + profiles: ['different'], + allowsGeneralCreation: true, + reason: 'fixture', + grantedByUserId: ids[0]!, + }, + }); + await expect(create(4)).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await prisma.specialAccountAccessGrant.updateMany({ + where: { userId: ids[4] }, + data: { profiles: [profileName] }, + }); + await expect(create(4)).resolves.toMatchObject({ status: 'PENDING' }); + await prisma.appUser.update({ + where: { id: ids[1] }, + data: { sanctions: { serverRestrictions: { [profileName]: { blockedFeatures: ['game'] } } } }, + }); + await expect(create()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + it('deduplicates concurrent endorsements and excludes self agreement', async () => { + await policy(3); + const r = await create(); + await expect(actOnOpenRequest(context(), await user(1), r.id, 'ENDORSE')).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await Promise.all( + Array.from({ length: 5 }, async () => actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE')) + ); + expect(await prisma.gatewayOpenEndorsement.count({ where: { requestId: r.id } })).toBe(1); + expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0); + }); + it('atomically reserves once at consensus, follows branch policy, supersedes other proposals', async () => { + await policy(2, { buildLeadMinutes: 25 }); + await prisma.gatewayProfile.update({ + where: { profileName }, + data: { + meta: { + releaseSource: { mode: 'BRANCH', ref: 'main' }, + openRequestPolicy: { endorsementThreshold: 2, buildLeadMinutes: 25 }, + }, + }, + }); + const r = await create(); + const other = await create(2); + await Promise.all([ + actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE'), + actOnOpenRequest(context(), await user(3), r.id, 'ENDORSE'), + ]); + const row = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } }); + const ops = await prisma.gatewayOperation.findMany({ where: { profileName } }); + expect(row.status).toBe('RESERVED'); + expect(ops).toHaveLength(1); + expect(ops[0]).toMatchObject({ + sourceMode: 'BRANCH', + sourceRef: 'main', + resolvedCommitSha: null, + status: 'QUEUED', + }); + expect(ops[0]!.scheduledAt!.getTime()).toBe(row.preopenAt.getTime() - 25 * 60_000); + expect((await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: other.id } })).status).toBe( + 'SUPERSEDED' + ); + expect(await profiles.claimNextOperation(new Date())).toBeNull(); + }); + it('keeps CHE-style manual approval and validates scoped opener capability', async () => { + expect(readOpenRequestPolicy({}).endorsementThreshold).toBeNull(); + const r = await create(); + await expect((await caller(2)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject( + { code: 'FORBIDDEN' } + ); + await expect(actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE')).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await Promise.all([ + (await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' }), + (await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' }), + ]); + expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(1); + }); + it('does not count a voter whose membership has been revoked', async () => { + await policy(2); + const r = await create(); + await actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE'); + await prisma.appUser.update({ where: { id: ids[2] }, data: { kakaoVerifiedAt: null } }); + await actOnOpenRequest(context(), await user(3), r.id, 'ENDORSE'); + expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0); + }); + it('rejects withdrawal by another user, permits own withdrawal, and rejects post-reservation withdrawal', async () => { + const r = await create(); + await expect(actOnOpenRequest(context(), await user(2), r.id, 'WITHDRAW')).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await actOnOpenRequest(context(), await user(1), r.id, 'WITHDRAW'); + expect((await listOpenRequests(context(), await user(1), profileName)).requests).toHaveLength(0); + const next = await create(); + await (await caller(3)).admin.openRequests.decide({ id: next.id, action: 'ACCEPT' }); + await expect(actOnOpenRequest(context(), await user(1), next.id, 'WITHDRAW')).rejects.toMatchObject({ + code: 'CONFLICT', + }); + }); + it('hides expired requests and refuses late approval without operation side effects', async () => { + const r = await create(); + // Keep DB temporal constraints valid while simulating an expired proposal. + await prisma.gatewayOpenRequest.update({ + where: { id: r.id }, + data: { createdAt: new Date(Date.now() - 2 * 86_400_000), expiresAt: new Date(Date.now() - 86_400_000) }, + }); + expect((await listOpenRequests(context(), await user(1), profileName)).requests).toHaveLength(0); + await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject( + { code: 'CONFLICT' } + ); + expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0); + }); + it('refuses stale seasons and a game reopened before approval', async () => { + const r = await create(); + await prisma.gatewayProfile.update({ where: { profileName }, data: { openAt: new Date() } }); + await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject( + { code: 'CONFLICT' } + ); + await prisma.gatewayProfile.update({ where: { profileName }, data: { openAt: null, status: 'RUNNING' } }); + await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject( + { code: 'CONFLICT' } + ); + }); + it('serializes approval against existing operations and leaves proposal pending on build incompatibility', async () => { + const r = await create(); + vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(false); + await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject( + { code: 'BAD_REQUEST' } + ); + expect((await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('PENDING'); + vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(true); + await profiles.createOperation({ profileName, type: 'START', requestedBy: ids[3]! }); + await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject( + { code: 'CONFLICT' } + ); + }); + it('immediately applies a lowered consensus threshold to existing agreements', async () => { + await policy(3); + const r = await create(); + await actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE'); + await prisma.appUser.update({ + where: { id: ids[3] }, + data: { roles: [`admin.profiles.settings:${profileName}`] }, + }); + await ( + await caller(3) + ).admin.openRequests.updatePolicy({ + profileName, + policy: { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 1 }, + reason: '동의 기준 조정', + }); + expect((await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: r.id } })).status).toBe('RESERVED'); + }); + it('rolls back a deciding endorsement if reservation fails and supports retry', async () => { + await policy(1); + const r = await create(); + vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(false); + await expect(actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE')).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(await prisma.gatewayOpenEndorsement.count({ where: { requestId: r.id } })).toBe(0); + vi.mocked(catalog.supportsProfileReleaseBuild).mockResolvedValue(true); + await expect(actOnOpenRequest(context(), await user(2), r.id, 'ENDORSE')).resolves.toMatchObject({ + status: 'RESERVED', + }); + }); + it('rejects equal, past and insufficient-lead dates and conflicting edits', async () => { + const input = install(); + await expect( + createOpenRequest(context(), await user(1), profileName, { ...input, preopenAt: input.openAt }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + createOpenRequest(context(), await user(1), profileName, { + ...input, + preopenAt: new Date(Date.now() + 60_000).toISOString(), + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await create(); + await expect( + createOpenRequest(context(), await user(1), profileName, { ...input, turnTermMinutes: 8 }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + }); + it('rejects a different-profile opener and policy changes by an ordinary member', async () => { + const r = await create(); + await prisma.appUser.update({ + where: { id: ids[3] }, + data: { roles: ['admin.scenarios.reset:other:default'] }, + }); + await expect((await caller(3)).admin.openRequests.decide({ id: r.id, action: 'ACCEPT' })).rejects.toMatchObject( + { code: 'FORBIDDEN' } + ); + await expect( + (await caller(2)).admin.openRequests.updatePolicy({ + profileName, + policy: { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 1 }, + reason: '권한 거부 확인', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(await prisma.gatewayOperation.count({ where: { profileName } })).toBe(0); + }); + it.runIf(process.env.OPEN_REQUEST_BROWSER === '1')( + 'persists and approves through Chromium, actual HTTP and PostgreSQL', + async () => { + const { chromium } = await import('@playwright/test'); + await policy(2); + const address = await app.listen({ host: '127.0.0.1', port: 0 }); + const browser = await chromium.launch({ headless: true }); + const browserContext = await browser.newContext({ + viewport: { width: 1200, height: 900 }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + colorScheme: 'dark', + }); + const session = await sessions.createSession(await user(1)); + await browserContext.addInitScript((token) => { + if (!localStorage.getItem('sammo-session-token')) localStorage.setItem('sammo-session-token', token); + }, session.sessionToken); + const page = await browserContext.newPage(); + const artifactDir = path.join(repositoryRoot, 'test-results/open-request-live'); + await fs.mkdir(artifactDir, { recursive: true }); + try { + await page.goto(`${address}/gateway/open-suggestion`); + await page.getByTestId('proposal-preopen').fill('2099-08-18T11:30'); + await page.getByTestId('proposal-open').fill('2099-08-18T12:00'); + await page.getByTestId('submit-open-request').click(); + await page.locator('.request-card').waitFor(); + const row = await prisma.gatewayOpenRequest.findFirstOrThrow({ where: { profileName } }); + expect(row.preopenAt.toISOString()).toBe('2099-08-18T02:30:00.000Z'); + expect(row.requestedBy).toBe(ids[1]); + await page.reload(); + await page.locator('.request-card').waitFor(); + const voterSession = await sessions.createSession(await user(2)); + await page.evaluate( + (token) => localStorage.setItem('sammo-session-token', token), + voterSession.sessionToken + ); + await page.reload(); + const endorse = page.getByRole('button', { name: '동의', exact: true }); + await endorse.waitFor(); + await endorse.hover(); + await endorse.focus(); + await endorse.click(); + await page.getByRole('button', { name: '동의 완료', exact: true }).waitFor(); + expect(await prisma.gatewayOpenEndorsement.count({ where: { requestId: row.id } })).toBe(1); + await page.screenshot({ path: path.join(artifactDir, 'pending-endorsement.png'), fullPage: true }); + const openerSession = await sessions.createSession(await user(3)); + await page.evaluate( + (token) => localStorage.setItem('sammo-session-token', token), + openerSession.sessionToken + ); + await page.reload(); + const accept = page.getByRole('button', { name: '수락하여 오픈 예약' }); + await accept.waitFor(); + await accept.hover(); + await accept.focus(); + await accept.click(); + await page.locator('.request-card').filter({ hasText: '준비 예약' }).waitFor(); + const reserved = await prisma.gatewayOpenRequest.findUniqueOrThrow({ where: { id: row.id } }); + expect(reserved.status).toBe('RESERVED'); + expect(await prisma.gatewayOperation.count({ where: { id: reserved.operationId! } })).toBe(1); + for (const viewport of [ + { width: 1200, height: 900 }, + { width: 390, height: 844 }, + ]) { + await page.setViewportSize(viewport); + await page.evaluate('document.fonts.ready'); + await page.locator('.request-card summary').click(); + const measurements = await page.locator('.request-list').evaluate((element) => { + const rect = element.getBoundingClientRect(), + style = element.ownerDocument.defaultView!.getComputedStyle(element); + return { + rect: rect.toJSON(), + font: style.font, + color: style.color, + documentWidth: element.ownerDocument.documentElement.scrollWidth, + viewportWidth: element.ownerDocument.defaultView!.innerWidth, + html: element.outerHTML, + }; + }); + expect(measurements.documentWidth).toBe(viewport.width); + await fs.writeFile( + path.join(artifactDir, `requests-${viewport.width}.json`), + JSON.stringify(measurements, null, 2) + ); + await page.screenshot({ + path: path.join(artifactDir, `requests-${viewport.width}.png`), + fullPage: true, + }); + } + } catch (error) { + await fs.writeFile(path.join(artifactDir, 'failure.html'), await page.content()); + await page.screenshot({ path: path.join(artifactDir, 'failure.png'), fullPage: true }); + throw error; + } finally { + await browser.close(); + } + }, + 60_000 + ); + it('enforces authentication and rejects user-supplied actor, source and build time over real HTTP', async () => { + const unauth = await app.inject({ + method: 'POST', + url: '/gateway/api/trpc/openRequests.create', + payload: { profileName, install: install() }, + }); + expect(unauth.statusCode).toBe(401); + const session = await sessions.createSession(await user(1)); + for (const extra of [ + { requestedBy: ids[3] }, + { scheduledAt: new Date().toISOString() }, + { sourceMode: 'BRANCH', sourceRef: 'evil' }, + ]) { + const response = await app.inject({ + method: 'POST', + url: '/gateway/api/trpc/openRequests.create', + headers: { 'x-session-token': session.sessionToken }, + payload: { profileName, install: install(), ...extra }, + }); + expect(response.statusCode).toBe(400); + } + const success = await app.inject({ + method: 'POST', + url: '/gateway/api/trpc/openRequests.create', + headers: { 'x-session-token': session.sessionToken }, + payload: { profileName, install: install() }, + }); + expect(success.statusCode).toBe(200); + expect(await prisma.gatewayOpenRequest.count({ where: { profileName } })).toBe(1); + }); +}); diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index d7d96228..1a58158e 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -669,6 +669,75 @@ describe('GatewayOrchestrator first-class operations', () => { ]); }); + it.each([ + { ...profile, status: 'RUNNING' as const }, + { ...profile, openAt: '2026-10-22T00:00:00.000Z' }, + ])('refuses a stale community reset before resolving or building code', async (changedProfile) => { + const reset: GatewayOperationRecord = { + ...buildOperation('START'), + type: 'RESET', + sourceMode: 'COMMIT', + sourceRef: profile.buildCommitSha, + payload: { + openRequestId: 'request', + openRequestSeasonKey: JSON.stringify([profile.currentScenario, null]), + }, + }; + const harness = createHarness(reset, false, false, true, false, undefined, undefined, { + profile: changedProfile, + }); + await harness.orchestrator.runOperationsNow(); + expect(harness.completions).toEqual(['FAILED']); + expect(harness.started).toHaveLength(0); + expect(harness.stopped).toHaveLength(0); + expect(harness.buildStatuses).toHaveLength(0); + expect(harness.completionFields[0]?.error).toContain('서버가 다시 열렸거나'); + }); + + it('allows an eligible community reservation to proceed into the existing build path', async () => { + const reset: GatewayOperationRecord = { + ...buildOperation('START'), + type: 'RESET', + sourceMode: 'COMMIT', + sourceRef: profile.buildCommitSha, + payload: { + openRequestId: 'request', + openRequestSeasonKey: JSON.stringify([profile.currentScenario, null]), + install: { scenarioId: 2, openAt: '2099-01-01T00:00:00.000Z' }, + }, + }; + const workspaceManager = { + resolveCommit: async () => profile.buildCommitSha!, + prepare: async () => { + throw new Error('fixture reached community build preparation'); + }, + remove: async () => {}, + } as unknown as GitWorkspaceManager; + const harness = createHarness(reset, false, false, true, false, workspaceManager); + await harness.orchestrator.runOperationsNow(); + expect(harness.completions).toEqual(['FAILED']); + expect(harness.completionFields[0]?.error).toBe('fixture reached community build preparation'); + }); + + it('refuses a community reservation after its opening deadline', async () => { + const reset: GatewayOperationRecord = { + ...buildOperation('START'), + type: 'RESET', + sourceMode: 'COMMIT', + sourceRef: profile.buildCommitSha, + payload: { + openRequestId: 'request', + openRequestSeasonKey: JSON.stringify([profile.currentScenario, null]), + install: { openAt: '2020-01-01T00:00:00.000Z' }, + }, + }; + const harness = createHarness(reset); + await harness.orchestrator.runOperationsNow(); + expect(harness.completions).toEqual(['FAILED']); + expect(harness.buildStatuses).toHaveLength(0); + expect(harness.completionFields[0]?.error).toContain('오픈 시각이 지나'); + }); + it('drains an in-flight operation before shutdown completes', async () => { let releaseStart: (() => void) | undefined; const startGate = new Promise((resolve) => { diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index d3254005..67d2b016 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -38,7 +38,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, - gatewaySchemaHead: '20260825000000_add_bulk_release_batches', + gatewaySchemaHead: '20261022000000_add_open_requests', gameSchemaHead: '20260916080000_index_play_audit_decision_month', }); }); diff --git a/app/gateway-frontend/e2e/open-suggestion.spec.ts b/app/gateway-frontend/e2e/open-suggestion.spec.ts index 61b448b9..b36f12fe 100644 --- a/app/gateway-frontend/e2e/open-suggestion.spec.ts +++ b/app/gateway-frontend/e2e/open-suggestion.spec.ts @@ -8,14 +8,22 @@ const operationNames = (route: Route): string[] => { return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); }; -const installFixture = async (page: Page): Promise => { +const installFixture = async (page: Page, opener = false): Promise => { const operations: string[] = []; + let savedRequest: Record | null = null; + let policy = { enabled: true, lifetimeDays: 7, buildLeadMinutes: 10, endorsementThreshold: 2 as number | null }; await page.addInitScript(() => { window.localStorage.setItem('sammo-session-token', 'regular-user-session'); }); await page.route('**/gateway/api/trpc/**', async (route) => { expect(route.request().headers()['x-session-token']).toBe('regular-user-session'); - const results = operationNames(route).map((operation) => { + const url = new URL(route.request().url()); + const raw = + route.request().method() === 'POST' + ? route.request().postDataJSON() + : JSON.parse(url.searchParams.get('input') ?? '{}'); + const results = operationNames(route).map((operation, index) => { + const input = url.searchParams.get('batch') === '1' ? raw[String(index)] : raw; operations.push(operation); if (operation === 'me') { return response({ @@ -52,6 +60,7 @@ const installFixture = async (page: Page): Promise => { ]); } if (operation === 'lobby.scenarios') { + expect(input).toEqual({ profileName: 'pya:default' }); return response([ { id: 2701, @@ -77,6 +86,48 @@ const installFixture = async (page: Page): Promise => { }, ]); } + if (operation === 'openRequests.list') { + expect(input).toEqual({ profileName: 'pya:default' }); + return response({ policy, canRequest: true, requests: savedRequest ? [savedRequest] : [] }); + } + if (operation === 'admin.openRequests.permissions') { + return response({ canAccept: opener, canManagePolicy: opener }); + } + if (operation === 'openRequests.create') { + expect(Object.keys(input).sort()).toEqual(['install', 'profileName']); + expect(input.profileName).toBe('pya:default'); + expect(input.install.preopenAt).toBe('2099-08-18T02:30:00.000Z'); + expect(input.install.openAt).toBe('2099-08-18T03:00:00.000Z'); + expect(input.install.scenarioId).toBe(2701); + expect(input.install).not.toHaveProperty('gitRef'); + savedRequest = { + id: '11111111-1111-4111-8111-111111111111', + status: 'PENDING', + requesterName: '일반유저', + isMine: true, + announcement: { scenarioTitle: '아시아 명장전', defaultStatTotal: 310 }, + install: input.install, + expiresAt: '2099-08-18T02:20:00.000Z', + createdAt: '2099-08-17T00:00:00.000Z', + endorsementCount: 0, + endorsed: false, + canEndorse: false, + operationStatus: null, + }; + return response({ id: savedRequest.id, status: savedRequest.status }); + } + if (operation === 'admin.openRequests.decide') { + expect(opener).toBe(true); + expect(input).toEqual({ id: savedRequest?.id, action: 'ACCEPT' }); + savedRequest = { ...savedRequest, status: 'RESERVED', operationStatus: 'QUEUED' }; + return response({ id: savedRequest.id, status: 'RESERVED' }); + } + if (operation === 'admin.openRequests.updatePolicy') { + expect(opener).toBe(true); + expect(input.reason).toBe('테스트 서버 정책 변경'); + policy = input.policy; + return response(policy); + } throw new Error(`Unhandled tRPC operation: ${operation}`); }); const batched = new URL(route.request().url()).searchParams.get('batch') === '1'; @@ -95,7 +146,7 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges const operations = await installFixture(page); await page.goto('lobby'); - const suggestionLink = page.getByRole('link', { name: '오픈 건의 양식 작성' }); + const suggestionLink = page.getByRole('link', { name: '오픈 요청 · 목록' }); await expect(suggestionLink).toBeVisible(); await suggestionLink.hover(); await suggestionLink.focus(); @@ -103,8 +154,10 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges await suggestionLink.click(); await expect(page).toHaveURL(/\/gateway\/open-suggestion$/); - await expect(page.getByRole('heading', { name: '오픈 건의 양식' })).toBeVisible(); - await expect(page.getByText('서버 설정, 시나리오, 오픈 시각은 변경되지 않습니다.')).toBeVisible(); + await expect(page.getByRole('heading', { name: '오픈 요청', exact: true })).toBeVisible(); + await expect( + page.getByText('복사는 문구만 복사하며, 오픈 요청 등록 버튼을 눌러야 요청이 저장됩니다.') + ).toBeVisible(); await expect(page.getByTestId('scenario-summary')).toContainText('310'); await page.getByTestId('proposal-open').fill('2026-08-18T12:00'); @@ -166,7 +219,77 @@ test('lets a regular user inspect active-build scenarios and copy an open sugges await page.screenshot({ path: testInfo.outputPath('open-suggestion-mobile.png'), fullPage: true }); expect(operations).toContain('lobby.scenarios'); - expect(operations.every((operation) => ['me', 'lobby.notice', 'lobby.profiles', 'lobby.scenarios'].includes(operation))).toBe( - true - ); + expect( + operations.every((operation) => + [ + 'me', + 'lobby.notice', + 'lobby.profiles', + 'lobby.scenarios', + 'openRequests.list', + 'admin.openRequests.permissions', + ].includes(operation) + ) + ).toBe(true); +}); + +test('registers a durable proposal and lets the opener accept and configure it across desktop/mobile', async ({ + page, +}, testInfo) => { + const operations = await installFixture(page, true); + await page.goto('open-suggestion'); + await expect(page.getByRole('heading', { name: '오픈 요청 목록' })).toBeVisible(); + await page.getByTestId('proposal-preopen').fill('2099-08-18T11:30'); + await page.getByTestId('proposal-open').fill('2099-08-18T12:00'); + const submit = page.getByTestId('submit-open-request'); + await submit.hover(); + await submit.focus(); + await expect(submit).toBeEnabled(); + await submit.click(); + const card = page.locator('.request-card'); + await expect(card).toContainText('일반유저'); + await expect(card).toContainText('수락 대기'); + await page.reload(); + await expect(card).toContainText('아시아 명장전'); + await card.getByText('요청 설정 전체').click(); + await expect(card).toContainText('시간 동기화'); + await card.getByRole('button', { name: '수락하여 오픈 예약' }).hover(); + await card.getByRole('button', { name: '수락하여 오픈 예약' }).click(); + await expect(card).toContainText('오픈 예약'); + await expect(card).toContainText('준비 예약'); + await expect(card.getByRole('button', { name: '요청 철회' })).toHaveCount(0); + await page.getByText('서버 오픈 요청 정책', { exact: true }).click(); + const policy = page.locator('.request-policy'); + await policy.getByLabel('가오픈 전 준비 시간(분)').fill('25'); + await policy.getByLabel('동의로 자동 예약').uncheck(); + await expect(policy.getByLabel('필요 동의 수(요청자 제외)')).toHaveCount(0); + await policy.getByLabel('변경 사유').fill('테스트 서버 정책 변경'); + await policy.getByRole('button', { name: '정책 저장' }).click(); + await expect(page.getByText('이 서버는 오프너가 수락하면 예약됩니다.')).toBeVisible(); + for (const viewport of [ + { width: 1200, height: 900 }, + { width: 390, height: 844 }, + ]) { + await page.setViewportSize(viewport); + await page.evaluate(() => document.fonts.ready); + const measurements = await page.locator('.request-list').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: rect.toJSON(), + color: style.color, + font: style.font, + width: innerWidth, + documentWidth: document.documentElement.scrollWidth, + html: element.outerHTML, + }; + }); + expect(measurements.documentWidth).toBe(viewport.width); + expect(measurements.rect.right).toBeLessThanOrEqual(viewport.width); + await writeFile(testInfo.outputPath(`requests-${viewport.width}.json`), JSON.stringify(measurements, null, 2)); + await page.screenshot({ path: testInfo.outputPath(`requests-${viewport.width}.png`), fullPage: true }); + } + expect(operations).toContain('openRequests.create'); + expect(operations).toContain('admin.openRequests.decide'); + expect(operations).toContain('admin.openRequests.updatePolicy'); }); diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 355d0302..2d407fea 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -802,13 +802,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {

- 시나리오와 빌드 옵션을 확인하고 운영자에게 전달할 오픈 건의 문구를 만들 수 있습니다. + 다음 오픈을 요청하고, 다른 사용자의 요청에 동의하거나 진행 상황을 확인할 수 있습니다.

- 오픈 건의 양식 작성 + 오픈 요청 · 목록
diff --git a/app/gateway-frontend/src/views/OpenSuggestionView.vue b/app/gateway-frontend/src/views/OpenSuggestionView.vue index f4d74494..c2ff99a9 100644 --- a/app/gateway-frontend/src/views/OpenSuggestionView.vue +++ b/app/gateway-frontend/src/views/OpenSuggestionView.vue @@ -1,4 +1,5 @@