feat: add per-profile reset defaults
This commit is contained in:
@@ -464,6 +464,24 @@ const zInstallOptions = z.object({
|
||||
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 zSourceMode = z.enum(['BRANCH', 'COMMIT']);
|
||||
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
|
||||
|
||||
@@ -533,6 +551,16 @@ const readMetaObject = (value: unknown): Record<string, unknown> => {
|
||||
return value as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const readProfileResetDefaults = (
|
||||
meta: Record<string, unknown>
|
||||
): { defaults: z.infer<typeof zProfileResetDefaults>; source: 'SYSTEM' | 'PROFILE' } => {
|
||||
const parsed = zProfileResetDefaults.partial().safeParse(meta.resetDefaults);
|
||||
if (meta.resetDefaults === undefined || !parsed.success) {
|
||||
return { defaults: { ...SYSTEM_PROFILE_RESET_DEFAULTS }, source: 'SYSTEM' };
|
||||
}
|
||||
return { defaults: { ...SYSTEM_PROFILE_RESET_DEFAULTS, ...parsed.data }, source: 'PROFILE' };
|
||||
};
|
||||
|
||||
const applyMetaPatch = (
|
||||
meta: Record<string, unknown>,
|
||||
patch: Record<string, unknown | null | undefined>
|
||||
@@ -1474,6 +1502,18 @@ export const adminRouter = router({
|
||||
}),
|
||||
}),
|
||||
profiles: router({
|
||||
getResetDefaults: adminProcedure
|
||||
.input(z.object({ profileName: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
assertAnyPermission(adminAuth, [ROLE_ADMIN_PROFILES, ROLE_ADMIN_SCENARIO_RESET], input.profileName);
|
||||
const profile = await ctx.profiles.getProfile(input.profileName);
|
||||
if (!profile) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found.' });
|
||||
}
|
||||
const meta = readMetaObject(profile.meta);
|
||||
return readProfileResetDefaults(meta);
|
||||
}),
|
||||
listNavigation: adminProcedure.query(async ({ ctx }) => {
|
||||
const adminAuth = requireAdminAuth(ctx);
|
||||
return orderGatewayProfiles(await ctx.profiles.listProfiles())
|
||||
@@ -1660,6 +1700,7 @@ export const adminRouter = router({
|
||||
nextSeasonIdx: z.number().int().min(0).nullable().optional(),
|
||||
localAccountAccessGraceDays: z.number().int().min(0).max(365).nullable().optional(),
|
||||
localAccountGeneralCreationGraceDays: z.number().int().min(0).max(365).nullable().optional(),
|
||||
resetDefaults: zProfileResetDefaults.nullable().optional(),
|
||||
}),
|
||||
reason: z.string().trim().min(3).max(200),
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ const buildCaller = async (
|
||||
initialNotice?: string;
|
||||
initialProfileStatus?: GatewayProfileRecord['status'];
|
||||
profileScenario?: string;
|
||||
profileMeta?: GatewayProfileRecord['meta'];
|
||||
releaseLogVisibilityAfterPolls?: number;
|
||||
} = {}
|
||||
) => {
|
||||
@@ -75,7 +76,7 @@ const buildCaller = async (
|
||||
status: options.initialProfileStatus ?? ('STOPPED' as const),
|
||||
buildStatus: 'SUCCEEDED' as const,
|
||||
buildCommitSha: 'HEAD',
|
||||
meta: {},
|
||||
meta: options.profileMeta ?? {},
|
||||
createdAt: '2026-07-25T00:00:00.000Z',
|
||||
updatedAt: '2026-07-25T00:00:00.000Z',
|
||||
};
|
||||
@@ -511,6 +512,102 @@ describe('admin operation API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns validated profile reset defaults to a scenario-only operator', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
{
|
||||
adminRoles: ['admin.scenarios.reset:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
profileMeta: {
|
||||
resetDefaults: {
|
||||
turnTermMinutes: 20,
|
||||
sync: false,
|
||||
fiction: 0,
|
||||
extend: false,
|
||||
blockGeneralCreate: 2,
|
||||
npcMode: 1,
|
||||
showImgLevel: 1,
|
||||
tournamentTrig: false,
|
||||
joinMode: 'onlyRandom',
|
||||
autorunUser: { limitMinutes: 720, options: ['develop', 'train'] },
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await expect(harness.caller.admin.profiles.getResetDefaults({ profileName: 'che:2' })).resolves.toEqual({
|
||||
source: 'PROFILE',
|
||||
defaults: {
|
||||
turnTermMinutes: 20,
|
||||
sync: false,
|
||||
fiction: 0,
|
||||
extend: false,
|
||||
blockGeneralCreate: 2,
|
||||
npcMode: 1,
|
||||
showImgLevel: 1,
|
||||
tournamentTrig: false,
|
||||
joinMode: 'onlyRandom',
|
||||
autorunUser: { limitMinutes: 720, options: ['develop', 'train'] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to system reset defaults when profile metadata is malformed', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
{
|
||||
adminRoles: ['admin.scenarios.reset:che:2'],
|
||||
firstUserIsAdmin: false,
|
||||
profileMeta: { resetDefaults: { npcMode: 99 } },
|
||||
}
|
||||
);
|
||||
|
||||
await expect(harness.caller.admin.profiles.getResetDefaults({ profileName: 'che:2' })).resolves.toMatchObject({
|
||||
source: 'SYSTEM',
|
||||
defaults: { turnTermMinutes: 60, npcMode: 0, tournamentTrig: true, autorunUser: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('stores reset defaults only after validating the complete metadata object', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
{ adminRoles: ['admin.profiles.settings:che:2'], firstUserIsAdmin: false }
|
||||
);
|
||||
const resetDefaults = {
|
||||
turnTermMinutes: 10,
|
||||
sync: true,
|
||||
fiction: 1 as const,
|
||||
extend: true,
|
||||
blockGeneralCreate: 0 as const,
|
||||
npcMode: 2 as const,
|
||||
showImgLevel: 3 as const,
|
||||
tournamentTrig: true,
|
||||
joinMode: 'full' as const,
|
||||
autorunUser: null,
|
||||
};
|
||||
|
||||
await harness.caller.admin.profiles.updateMeta({
|
||||
profileName: 'che:2',
|
||||
patch: { resetDefaults },
|
||||
reason: 'set server reset defaults',
|
||||
});
|
||||
expect(harness.updatedMetas.at(-1)).toMatchObject({ resetDefaults });
|
||||
|
||||
await expect(
|
||||
harness.caller.admin.profiles.updateMeta({
|
||||
profileName: 'che:2',
|
||||
patch: { resetDefaults: { ...resetDefaults, turnTermMinutes: 7 } },
|
||||
reason: 'reject invalid reset defaults',
|
||||
})
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('does not let a scenario-only operator combine a Git update with reset', async () => {
|
||||
const harness = await buildCaller(
|
||||
async () => {
|
||||
|
||||
Reference in New Issue
Block a user