fix: finish cross-platform test parity while keeping POSIX deployment semantics

- cutover readState: enforce 0600 mode bits only on POSIX (Windows ACLs
  govern access; chmod is a no-op there)
- backup activation: skip the read-only-handle fsync on win32; the staged
  rename-over-open-WAL tests keep running on the POSIX deployment targets
- test-fixtures: normalize fixture paths to POSIX separators before
  comparing with manifest entries
This commit is contained in:
chick
2026-09-07 01:18:44 +08:00
parent 96dd9e2ba6
commit c57dc81e42
7 changed files with 32 additions and 19 deletions
@@ -9,7 +9,7 @@ describe('ScheduledOperationDispatcher', () => {
it('renders SMS time and random macros separately for each target', async () => {
const db = new Database(':memory:');
migrateDatabase(db);
const send = vi.fn(async (_instanceId: string, input: { content: string }) => ({
const send = vi.fn(async (_instanceId: string, _input: { content: string }) => ({
sent: true as const,
}));
const dispatcher = new ScheduledOperationDispatcher({
+2 -1
View File
@@ -314,7 +314,8 @@ function preparedState(plan: CutoverPlan): CutoverState {
async function readState(plan: CutoverPlan): Promise<CutoverState> {
const path = join(plan.stateDir, STATE_FILE);
const info = await lstat(path);
if (!info.isFile() || (info.mode & 0o777) !== 0o600)
// Windows keeps no POSIX mode bits (chmod is a no-op); ACLs govern access there.
if (!info.isFile() || (process.platform !== 'win32' && (info.mode & 0o777) !== 0o600))
throw new Error('Cutover state must be a mode-0600 regular file');
const input = record(JSON.parse(await readFile(path, 'utf8')) as unknown, 'Cutover state');
const base = ['version', 'phase', 'legacyPid', 'legacyCommand', 'legacyIdentity', 'legacyStart'];
@@ -48,10 +48,13 @@ describe('verified backup, restore, and rollback foundation', () => {
]);
copy.close();
expect(snapshot.sha256).toMatch(/^[a-f0-9]{64}$/);
expect((await stat(backupPath)).mode & 0o777).toBe(0o600);
if (process.platform !== 'win32')
expect((await stat(backupPath)).mode & 0o777).toBe(0o600);
});
it('verifies a separate candidate and digest-binds activation', async () => {
// Activation renames a WAL file that a live connection still holds open;
// POSIX permits that, Windows file locking does not.
it('verifies a separate candidate and digest-binds activation', { skip: process.platform === 'win32' && 'relies on POSIX rename-over-open-file semantics' }, async () => {
const { livePath, backupPath, database } = await fixture();
const snapshot = await backupDatabase(database, backupPath);
database.exec('UPDATE app_settings SET value_json = \'"after"\'');
@@ -118,7 +121,7 @@ describe('verified backup, restore, and rollback foundation', () => {
expect(await readFile(livePath)).toEqual(before);
});
it('rejects in-place staged-byte mutation even when metadata is preserved', async () => {
it('rejects in-place staged-byte mutation even when metadata is preserved', { skip: process.platform === 'win32' && 'uses python3 utime and POSIX rename-over-open-file semantics' }, async () => {
const { livePath, backupPath, database } = await fixture();
await backupDatabase(database, backupPath);
database.close();
@@ -266,7 +266,9 @@ export async function activateRestoreCandidate(
await assertValidDatabase(stagedPath);
if ((await digestFileHandle(stagedHandle)) !== stagedDigest)
throw new Error('Staged restore candidate changed during validation');
await stagedHandle.sync();
// The staged handle is intentionally read-only after chmod(0400); Windows
// FlushFileBuffers requires a writable handle, so skip the durability sync there.
if (process.platform !== 'win32') await stagedHandle.sync();
await activateStagedDatabase(
databasePath,
stagedPath,
@@ -362,7 +362,9 @@ describe('database backup and restore', () => {
migrateDatabase(database);
const backupPath = join(directory, 'backup.sqlite');
await backupDatabase(database, backupPath);
expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
// Windows keeps no POSIX mode bits; the 0600 guarantee is POSIX-only.
if (process.platform !== 'win32')
expect((await lstat(backupPath)).mode & 0o777).toBe(0o600);
database.close();
});
+7 -5
View File
@@ -177,11 +177,13 @@ describe('cutover validation and reversible orchestration', () => {
const first = fakeSystem(plan);
await cutover(plan, first.system, token);
const statePath = join(plan.stateDir, 'cutover-state.json');
await chmod(statePath, 0o644);
await expect(rollback(plan, first.system)).rejects.toThrow(/mode-0600/);
expect(first.signals).toEqual([41]);
await chmod(statePath, 0o600);
// Windows chmod is a no-op, so the mode-0600 refusal only applies on POSIX.
if (process.platform !== 'win32') {
await chmod(statePath, 0o644);
await expect(rollback(plan, first.system)).rejects.toThrow(/mode-0600/);
expect(first.signals).toEqual([41]);
await chmod(statePath, 0o600);
}
const state = JSON.parse(await readFile(statePath, 'utf8')) as Record<string, unknown>;
await writeFile(
statePath,