feat: 정식 사용자의 오픈 요청과 동의 기반 예약을 영속화한다
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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<typeof zProfileResetDefaults> = {
|
||||
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<typeof zOperationInstallOptions>): 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);
|
||||
|
||||
@@ -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<string, unknown>): 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();
|
||||
@@ -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<void> => {
|
||||
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);
|
||||
}),
|
||||
});
|
||||
@@ -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<typeof zOpeningInstall>;
|
||||
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<Awaited<ReturnType<GatewayPrismaClient['gatewayProfile']['findUnique']>>>;
|
||||
type Request = NonNullable<Awaited<ReturnType<GatewayPrismaClient['gatewayOpenRequest']['findUnique']>>>;
|
||||
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<boolean> => {
|
||||
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<string, unknown>,
|
||||
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<void> => {
|
||||
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<string, unknown>);
|
||||
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<OpenRequestList> => {
|
||||
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<string, unknown>);
|
||||
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<string>();
|
||||
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 <T>(
|
||||
ctx: GatewayApiContext,
|
||||
profileName: string,
|
||||
run: (tx: GatewayPrisma.TransactionClient, profile: Profile, now: Date) => Promise<T>
|
||||
): Promise<T> =>
|
||||
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<Array<{ now: Date }>>`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<OpenRequestResult> =>
|
||||
inProfileTransaction(ctx, profileName, async (tx, profile, now) => {
|
||||
await assertMember(ctx, user, profile, now);
|
||||
const policy = readOpenRequestPolicy(profile.meta as Record<string, unknown>);
|
||||
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<string, unknown>);
|
||||
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<Array<{ now: Date }>>`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<OpenRequestResult> => {
|
||||
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<string, unknown>);
|
||||
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<OpenRequestPolicy> =>
|
||||
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;
|
||||
});
|
||||
@@ -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<void>,
|
||||
operationId?: string,
|
||||
releaseSource?: ProfileReleaseSource
|
||||
releaseSource?: ProfileReleaseSource,
|
||||
communityReservation?: { seasonKey: unknown; openAt: unknown }
|
||||
): Promise<GatewayAdminActionResult> {
|
||||
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<void> => {
|
||||
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')
|
||||
);
|
||||
|
||||
@@ -13,7 +13,9 @@ export interface ProfileReleaseSource {
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
|
||||
export const readProfileReleaseSource = (profile: GatewayProfileRecord): ProfileReleaseSource | null => {
|
||||
export const readProfileReleaseSource = (
|
||||
profile: Pick<GatewayProfileRecord, 'meta' | 'buildCommitSha'>
|
||||
): 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() : '';
|
||||
|
||||
@@ -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<typeof zProfileResetDefaults> = {
|
||||
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<typeof zOperationInstallOptions>): 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(', ');
|
||||
};
|
||||
@@ -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 <T extends 'login' | 'verif
|
||||
};
|
||||
|
||||
export const appRouter = router({
|
||||
openRequests: openRequestsRouter,
|
||||
navigation: router({
|
||||
get: procedure.query(({ ctx }) => ctx.navigationConfig.get()),
|
||||
}),
|
||||
|
||||
@@ -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<string, string | string[] | undefined> = {}) =>
|
||||
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<string, number> = {}) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void>((resolve) => {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user