Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
],
"bugs": "https://github.com/tedious-code/foxschema/issues",
"scripts": {
"typecheck": "tsc --noEmit -p apps/web/tsconfig.json && tsc --noEmit -p apps/e2e/tsconfig.json",
"typecheck": "tsc --noEmit -p apps/web/tsconfig.json && tsc --noEmit -p apps/e2e/tsconfig.json && tsc --noEmit -p apps/cli/tsconfig.json",
"lint": "eslint .",
"lint:security": "eslint . --config eslint.security.config.js --max-warnings 0",
"test-ids": "node scripts/test-ids/extract-test-ids.mjs --write",
Expand Down
80 changes: 80 additions & 0 deletions packages/server/src/features/migration/migration.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Fox Schema (foxschema)
* Copyright 2024-2026 Huy Phan <huyplb@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*
* Running a migration needs `schema.migrate`, checked at the route.
*
* `/migration/execute` applies DDL to a real database, and unlike Compare it
* has no service of its own that checks the permission again: the route guard
* is the only thing between a viewer and the target. So the refusal has to
* come before anything else, the connection lookup included.
*/
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Permission } from '@foxschema/shared';
import type { AuthedRequest } from '../auth/auth.routes';
import { bindRoutes } from '../../platform/http/fastify-bind';
import { createMigrationRoutes } from './migration.routes';

let app: FastifyInstance | undefined;

afterEach(async () => {
await app?.close();
app = undefined;
});

async function serve(permissions: Permission[]) {
// Rejecting stops a permitted request at the connection lookup, so nothing
// past the guard needs a real database.
const resolveRef = vi.fn().mockRejectedValue(new Error('no such connection'));
const executeMigration = vi.fn();
const router = createMigrationRoutes({
resolveRef,
migrationModule: { executeMigration },
migrationHistory: {},
connectionModule: {},
sqlGenerator: {},
captureLiveSchema: vi.fn(),
normalizeTableSchemas: vi.fn(),
gitMigrations: { read: vi.fn(), recordApplied: vi.fn(), commitRequired: vi.fn(), canSee: vi.fn() },
});
app = Fastify();
app.addHook('onRequest', async (req) => {
const authed = req as unknown as AuthedRequest;
authed.userId = 'some-user';
authed.appRole = 'viewer';
authed.permissions = new Set(permissions);
});
bindRoutes(app, router.flatten());
await app.ready();
return { server: app, resolveRef, executeMigration };
}

const execute = (server: FastifyInstance) =>
server.inject({
method: 'POST',
url: '/migration/execute',
payload: { connectionId: 'c1', steps: [{ sql: 'DROP TABLE orders' }] },
});

describe('POST /migration/execute', () => {
it('refuses an actor without schema.migrate before looking up the connection', async () => {
// Everything short of migrating: browsing, comparing, running SQL.
const { server, resolveRef, executeMigration } = await serve(['schema.browse', 'schema.compare', 'editor.run']);
const res = await execute(server);

expect(res.statusCode).toBe(403);
expect(res.json().code).toBe('forbidden');
expect(resolveRef).not.toHaveBeenCalled();
expect(executeMigration).not.toHaveBeenCalled();
});

it('lets an actor with schema.migrate past the guard', async () => {
const { server, resolveRef } = await serve(['schema.migrate']);
const res = await execute(server);

expect(res.json().code).not.toBe('forbidden');
expect(resolveRef).toHaveBeenCalledOnce();
});
});
Loading