test(scum): add migration parity fixtures

This commit is contained in:
npc0-hue
2026-07-29 15:39:42 +08:00
parent 6c503e567c
commit 7ae4dbf0b2
4 changed files with 54 additions and 11 deletions
@@ -11,14 +11,21 @@ server-side schema, identity binding, acknowledgement contract, or isolated
non-production fixture for login/logout events, network correlation, position
or vehicle events, reward delivery, or state reads and writes.
Controlled read-only migration fixtures now verify configuration, player
history, gifts, state-patch audits, and trajectories in
`plugins/tests/fixtures/scum-migration-parity.ts`. They preserve only
allowlisted plugin fields and maintain transition provenance. The existing
isolated Companion tests continue to cover its safe command boundary. Neither
test set provides an end-to-end SCUM executor or event producer.
Consequently these tasks remain open and blocked rather than simulated:
- 4.14.4: no legitimate versioned event producer exists for semantic player,
network, position, or vehicle data.
- 5.1 and 5.3: no documented, version-bound state-patch or reward-delivery
adapter exists.
- 5.5 and 6.2: the required adapter coverage and isolated non-production
fixtures do not exist.
- 5.5 and the remaining end-to-end portion of 6.2: the required adapter
coverage and isolated non-production SCUM fixtures do not exist.
- 6.3: transitional SCUM APIs and models still have callers and cannot be
removed before the parity and rollback evidence required by 6.2.
- 6.4: final full verification is deferred until the blocked adapters and
@@ -1,4 +1,5 @@
import type { SCUMConfigRead, SCUMFeatureKey, SCUMFeatureMigrationAuthority, SCUMFeatureMigrationStatus, SCUMGiftGrant, SCUMMigrationRecord, SCUMPlayer, SCUMPlayerProfile, SCUMPlayerRisk, SCUMPlayerSession, SCUMStatePatch, SCUMTrajectory, SCUMTrajectoryPoint } from "./contracts.js";
import { configurationCatalog } from "./schemas.js";
export function transitionalReadOnly<T extends Record<string, unknown>>(payload: T, recordedAt: string, sourceRecordId?: string): SCUMMigrationRecord<T> { return { provenance: "transitional-read-only", readOnly: true, payload, recordedAt, sourceRecordId }; }
export function pluginOwned<T extends Record<string, unknown>>(payload: T, recordedAt: string): SCUMMigrationRecord<T> { return { provenance: "plugin", readOnly: false, payload, recordedAt }; }
@@ -17,17 +18,18 @@ export function migrationStatus(flags: readonly SCUMFeatureMigrationAuthority[],
export function migratePlayerRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayer> | null {
const id = text(record.id); const gamePlayerId = text(record.gamePlayerId); const displayName = text(record.displayName); if (!id || !gamePlayerId || !displayName) return null;
return transitionalReadOnly({ id, gamePlayerId, displayName, lastSeenAt: optionalText(record.lastSeenAt), status: record.online === true ? "online" : "unknown" }, optionalText(record.updatedAt) ?? new Date(0).toISOString(), id);
const lastSeenAt = timestamp(record.lastSeenAt); const recordedAt = timestamp(record.updatedAt) ?? lastSeenAt ?? new Date(0).toISOString();
return transitionalReadOnly({ id, gamePlayerId, displayName, ...(lastSeenAt ? { lastSeenAt } : {}), status: record.online === true ? "online" : "unknown" }, recordedAt, id);
}
export function migrateConfigurationRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMConfigRead> | null {
const version = text(record.version) ?? text(record.gameVersion); const fields = stringFields(record.fields); const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!version || !fields || !observedAt) return null;
const version = text(record.version) ?? text(record.gameVersion); const fields = version ? allowlistedConfigFields(version, record.fields) : null; const observedAt = timestamp(record.observedAt) ?? timestamp(record.updatedAt); if (!version || !fields || !observedAt) return null;
return transitionalReadOnly({ version, fields, observedAt }, observedAt, text(record.id));
}
export function migratePlayerProfileRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMPlayerProfile> | null {
const player = migratePlayerRecord(object(record.player) ?? record); if (!player) return null;
const sessions = array(record.sessions).map(migrateSession).filter((item): item is SCUMPlayerSession => item !== null);
const sessions = array(record.sessions).map((item) => migrateSession(item, player.payload.id)).filter((item): item is SCUMPlayerSession => item !== null);
const risks = [...array(record.accessAttempts), ...array(record.securitySignals)].map(migrateRisk).filter((item): item is SCUMPlayerRisk => item !== null);
const recordedAt = timestamp(record.updatedAt) ?? player.recordedAt;
return transitionalReadOnly({ player: player.payload, sessions, risks }, recordedAt, player.sourceRecordId);
@@ -46,8 +48,9 @@ export function migrateStatePatchRecord(record: Record<string, unknown>): SCUMMi
}
export function migrateTrajectoryRecord(record: Record<string, unknown>): SCUMTrajectory | null {
const subjectId = text(record.playerRecordId) ?? text(record.vehicleId); const subjectType = text(record.playerRecordId) ? "player" : "vehicle"; const points = Array.isArray(record.points) ? record.points.map(migratePoint).filter((point): point is SCUMTrajectoryPoint => point !== null) : [];
return subjectId && points.length ? { subjectId, subjectType, points, provenance: "transitional-read-only" } : null;
const subjectId = text(record.playerRecordId) ?? text(record.gamePlayerRecordId) ?? text(record.vehicleId) ?? text(record.entityId); const subjectType = trajectorySubjectType(record); const points = Array.isArray(record.points) && subjectId && subjectType ? record.points.map((point) => migratePoint(point, subjectId, subjectType)).filter((point): point is SCUMTrajectoryPoint => point !== null) : [];
if (!subjectId || !subjectType || !points.length) return null;
return { subjectId, subjectType, points, provenance: "transitional-read-only" };
}
export function migrateTrajectoryHistoryRecord(record: Record<string, unknown>): SCUMMigrationRecord<SCUMTrajectory> | null {
@@ -56,13 +59,14 @@ export function migrateTrajectoryHistoryRecord(record: Record<string, unknown>):
return transitionalReadOnly(trajectory, recordedAt, text(record.id));
}
function migratePoint(value: unknown): SCUMTrajectoryPoint | null { if (!value || typeof value !== "object") return null; const record = value as Record<string, unknown>; const x = number(record.mapX) ?? number(record.worldX); const y = number(record.mapY) ?? number(record.worldY); const occurredAt = text(record.recordedAt) ?? text(record.occurredAt); if (x === undefined || y === undefined || !occurredAt) return null; return { occurredAt, subjectId: text(record.playerRecordId) ?? text(record.vehicleId) ?? "unknown", subjectType: text(record.playerRecordId) ? "player" : "vehicle", x, y, z: number(record.worldZ), source: "transitional-read-only" }; }
function migrateSession(value: unknown): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId)); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; }
function migratePoint(value: unknown, defaultSubjectId: string, defaultSubjectType: SCUMTrajectoryPoint["subjectType"]): SCUMTrajectoryPoint | null { const record = object(value); if (!record) return null; const x = number(record.mapX) ?? number(record.worldX); const y = number(record.mapY) ?? number(record.worldY); const occurredAt = timestamp(record.recordedAt) ?? timestamp(record.occurredAt); if (x === undefined || y === undefined || !occurredAt) return null; const subjectId = text(record.playerRecordId) ?? text(record.gamePlayerRecordId) ?? text(record.vehicleId) ?? text(record.entityId) ?? defaultSubjectId; const subjectType = trajectorySubjectType(record) ?? defaultSubjectType; return { occurredAt, subjectId, subjectType, x, y, ...(number(record.worldZ) !== undefined ? { z: number(record.worldZ) } : {}), source: "transitional-read-only" }; }
function migrateSession(value: unknown, defaultPlayerId: string): SCUMPlayerSession | null { const record = object(value); const id = record && text(record.id); const playerId = record && (text(record.gamePlayerRecordId) ?? text(record.playerId) ?? defaultPlayerId); const startedAt = record && timestamp(record.startedAt); if (!id || !playerId || !startedAt) return null; const endedAt = timestamp(record.endedAt); return { id, playerId, kind: endedAt ? "logout" : "login", occurredAt: endedAt ?? startedAt }; }
function migrateRisk(value: unknown): SCUMPlayerRisk | null { const record = object(value); const observedAt = record && (timestamp(record.occurredAt) ?? timestamp(record.lastObservedAt)); const kind = record && (text(record.ruleKey) ?? text(record.outcome)); const summary = record && (text(record.summary) ?? text(record.reason)); if (!observedAt || !kind || !summary) return null; return { kind, level: "medium", observedAt, summary }; }
function migrateStateChange(value: unknown): { fieldKey: string; before: number; after: number } | null { const record = object(value); if (!record) return null; const fieldKey = text(record.fieldKey); const before = number(record.before); const after = number(record.after); return fieldKey && before !== undefined && after !== undefined ? { fieldKey, before, after } : null; }
function stringFields(value: unknown): Record<string, string> | null { const fields = object(value); if (!fields) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (!text(key) || !(typeof field === "string" || typeof field === "number" || typeof field === "boolean")) return null; result[key] = String(field); } return result; }
function allowlistedConfigFields(version: string, value: unknown): Record<string, string> | null { const fields = object(value); const allowed = new Set(configurationCatalog(version).map((field) => field.configKey)); if (!fields || !allowed.size) return null; const result: Record<string, string> = {}; for (const [key, field] of Object.entries(fields)) { if (allowed.has(key) && (typeof field === "string" || typeof field === "number" || typeof field === "boolean")) result[key] = String(field); } return Object.keys(result).length ? result : null; }
function giftStatus(value: unknown): SCUMGiftGrant["status"] | null { return value === "pending-approval" || value === "queued" || value === "delivered" || value === "notification_failed" || value === "failed" || value === "unknown" ? value : null; }
function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown") return value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "execution-unknown" || value === "confirmation-failed" || value === "failed" ? "failed" : null; }
function stateStatus(value: unknown): SCUMStatePatch["status"] | null { if (value === "pending-approval" || value === "queued" || value === "unsupported" || value === "unknown" || value === "execution-unknown") return value === "execution-unknown" ? "unknown" : value; if (value === "confirmed") return "succeeded"; return value === "execution-failed" || value === "confirmation-failed" || value === "failed" ? "failed" : null; }
function trajectorySubjectType(record: Record<string, unknown>): SCUMTrajectoryPoint["subjectType"] | null { if (record.kind === "player" || record.kind === "vehicle") return record.kind; return text(record.playerRecordId) || text(record.gamePlayerRecordId) ? "player" : text(record.vehicleId) ? "vehicle" : null; }
function array(value: unknown): unknown[] { return Array.isArray(value) ? value : []; }
function object(value: unknown): Record<string, unknown> | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null; }
function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; }
+22
View File
@@ -0,0 +1,22 @@
export const scumMigrationParityFixtures = {
configuration: {
source: { id: "config-1", version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: 64, hostPath: "/srv/scum", RconPassword: "not-migrated" } },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "config-1", recordedAt: "2026-07-29T00:00:00Z", payload: { version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { ServerName: "Crystal Moon", MaxPlayers: "64" } } }
},
playerHistory: {
source: { updatedAt: "2026-07-29T00:10:00Z", player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", online: true }, sessions: [{ id: "session-1", startedAt: "2026-07-29T00:01:00Z", endedAt: "2026-07-29T00:08:00Z", networkFingerprint: "not-migrated" }], accessAttempts: [{ occurredAt: "2026-07-29T00:02:00Z", outcome: "review", reason: "manual review", networkCorrelationKey: "not-migrated" }], securitySignals: [{ lastObservedAt: "2026-07-29T00:03:00Z", ruleKey: "repeat-access", summary: "manual review", evidenceCount: 2 }] },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "player-1", recordedAt: "2026-07-29T00:10:00Z", payload: { player: { id: "player-1", gamePlayerId: "steam-1", displayName: "Mira", lastSeenAt: "2026-07-29T00:09:00Z", status: "online" }, sessions: [{ id: "session-1", playerId: "player-1", kind: "logout", occurredAt: "2026-07-29T00:08:00Z" }], risks: [{ kind: "review", level: "medium", observedAt: "2026-07-29T00:02:00Z", summary: "manual review" }, { kind: "repeat-access", level: "medium", observedAt: "2026-07-29T00:03:00Z", summary: "manual review" }] } }
},
gift: {
source: { id: "gift-1", revisionId: "revision-1", gamePlayerRecordId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z", deliveryCommandId: "not-migrated" },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "gift-1", recordedAt: "2026-07-29T00:20:00Z", payload: { id: "gift-1", revisionId: "revision-1", playerId: "player-1", notice: "Welcome", status: "unknown", createdAt: "2026-07-29T00:20:00Z", completedAt: "2026-07-29T00:21:00Z" } }
},
statePatch: {
source: { id: "patch-1", gamePlayerRecordId: "player-1", gameVersion: "0.9.700.90357", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "execution-unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }], bridgeCommandId: "not-migrated" },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "patch-1", recordedAt: "2026-07-29T00:30:00Z", payload: { id: "patch-1", playerId: "player-1", gameVersion: "0.9.700.90357", expectedStateVersion: "state-1", safetyWindow: "maintenance", reason: "verified test", status: "unknown", createdAt: "2026-07-29T00:30:00Z", changes: [{ fieldKey: "skills.running", before: 1, after: 2 }] } }
},
trajectory: {
source: { id: "trajectory-1", updatedAt: "2026-07-29T00:40:00Z", kind: "player", entityId: "steam-1", gamePlayerRecordId: "player-1", points: [{ mapX: 10, mapY: 20, occurredAt: "2026-07-29T00:39:00Z", source: "log-projection" }, { mapX: 30, mapY: 40, occurredAt: "not-a-timestamp" }] },
expected: { provenance: "transitional-read-only", readOnly: true, sourceRecordId: "trajectory-1", recordedAt: "2026-07-29T00:40:00Z", payload: { subjectId: "player-1", subjectType: "player", provenance: "transitional-read-only", points: [{ occurredAt: "2026-07-29T00:39:00Z", subjectId: "player-1", subjectType: "player", x: 10, y: 20, source: "transitional-read-only" }] } }
}
} as const;
+10
View File
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
import { configurationCatalog, validateConfigPatch, validateStatePatch } from "../examples/scum-server-plugin/features/schemas.js";
import { scumMigrationParityFixtures } from "./fixtures/scum-migration-parity.js";
describe("SCUM plugin feature module", () => {
it("owns the versioned configuration and state field catalogs", () => {
@@ -25,6 +26,15 @@ describe("SCUM plugin feature module", () => {
expect(migrateTrajectoryHistoryRecord({ id: "track-1", playerRecordId: "p-1", points: [{ recordedAt: "2026-07-29T00:00:00Z", mapX: 10, mapY: 20 }] })).toMatchObject({ sourceRecordId: "track-1", readOnly: true });
});
it("matches controlled transitional fixtures without carrying sensitive fields into plugin history", () => {
expect(migrateConfigurationRecord(scumMigrationParityFixtures.configuration.source)).toEqual(scumMigrationParityFixtures.configuration.expected);
expect(migratePlayerProfileRecord(scumMigrationParityFixtures.playerHistory.source)).toEqual(scumMigrationParityFixtures.playerHistory.expected);
expect(migrateGiftGrantRecord(scumMigrationParityFixtures.gift.source)).toEqual(scumMigrationParityFixtures.gift.expected);
expect(migrateStatePatchRecord(scumMigrationParityFixtures.statePatch.source)).toEqual(scumMigrationParityFixtures.statePatch.expected);
expect(migrateTrajectoryHistoryRecord(scumMigrationParityFixtures.trajectory.source)).toEqual(scumMigrationParityFixtures.trajectory.expected);
expect(migrateConfigurationRecord({ version: "0.9.700.90357", observedAt: "2026-07-29T00:00:00Z", fields: { hostPath: "/srv/scum" } })).toBeNull();
});
it("enables plugin authority only for one exact server-version feature flag", () => {
const flags = [{ serverInstanceId: "server-1", serverVersion: "0.9.700.90357", feature: "configuration" as const, authority: "plugin" as const }];
expect(migrationStatus(flags, "server-1", "0.9.700.90357", "configuration")).toMatchObject({ authority: "plugin", pluginWritesEnabled: true, readOnlyHistory: true });