Skip to content

Commit 0e1c0d3

Browse files
committed
fix: mark completed till validation for migration reset
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent d2f47ef commit 0e1c0d3

7 files changed

Lines changed: 152 additions & 24 deletions

File tree

src/http/routes/admin/migrations.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { multitenantKnex } from '@internal/database'
22
import {
3-
DBMigration,
3+
isDBMigrationName,
44
resetMigrationsOnTenants,
55
runMigrationsOnAllTenants,
66
} from '@internal/database/migrations'
@@ -30,27 +30,25 @@ export default async function routes(fastify: FastifyInstance) {
3030
return reply.status(400).send({ message: 'Queue is not enabled' })
3131
}
3232

33-
const { untilMigration, markCompletedTillMigration } = req.body as any
33+
const { untilMigration, markCompletedTillMigration } = req.body as Record<string, unknown>
3434

35-
if (
36-
typeof untilMigration !== 'string' ||
37-
!DBMigration[untilMigration as keyof typeof DBMigration]
38-
) {
35+
if (!isDBMigrationName(untilMigration)) {
3936
return reply.status(400).send({ message: 'Invalid migration' })
4037
}
4138

4239
if (
4340
typeof markCompletedTillMigration === 'string' &&
44-
!DBMigration[untilMigration as keyof typeof DBMigration]
41+
!isDBMigrationName(markCompletedTillMigration)
4542
) {
4643
return reply.status(400).send({ message: 'Invalid migration' })
4744
}
4845

4946
await resetMigrationsOnTenants({
50-
till: untilMigration as keyof typeof DBMigration,
51-
markCompletedTillMigration: markCompletedTillMigration
52-
? markCompletedTillMigration
53-
: undefined,
47+
till: untilMigration,
48+
markCompletedTillMigration:
49+
isDBMigrationName(markCompletedTillMigration)
50+
? markCompletedTillMigration
51+
: undefined,
5452
signal: req.signals.disconnect.signal,
5553
})
5654

src/http/routes/admin/tenants.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
TenantMigrationStatus,
99
} from '@internal/database'
1010
import {
11-
DBMigration,
11+
isDBMigrationName,
1212
lastLocalMigrationName,
1313
progressiveMigrations,
1414
resetMigration,
@@ -596,20 +596,17 @@ export default async function routes(fastify: FastifyInstance) {
596596
})
597597

598598
fastify.post<tenantRequestInterface>('/:tenantId/migrations/reset', async (req, reply) => {
599-
const { untilMigration, markCompletedTillMigration } = req.body
599+
const { untilMigration, markCompletedTillMigration } = req.body as Record<string, unknown>
600600

601601
const { databaseUrl } = await getTenantConfig(req.params.tenantId)
602602

603-
if (
604-
typeof untilMigration !== 'string' ||
605-
!DBMigration[untilMigration as keyof typeof DBMigration]
606-
) {
603+
if (!isDBMigrationName(untilMigration)) {
607604
return reply.status(400).send({ message: 'Invalid migration' })
608605
}
609606

610607
if (
611608
typeof markCompletedTillMigration === 'string' &&
612-
!DBMigration[untilMigration as keyof typeof DBMigration]
609+
!isDBMigrationName(markCompletedTillMigration)
613610
) {
614611
return reply.status(400).send({ message: 'Invalid migration' })
615612
}
@@ -618,10 +615,11 @@ export default async function routes(fastify: FastifyInstance) {
618615
await resetMigration({
619616
tenantId: req.params.tenantId,
620617
databaseUrl,
621-
untilMigration: untilMigration as keyof typeof DBMigration,
622-
markCompletedTillMigration: markCompletedTillMigration
623-
? (markCompletedTillMigration as keyof typeof DBMigration)
624-
: undefined,
618+
untilMigration,
619+
markCompletedTillMigration:
620+
isDBMigrationName(markCompletedTillMigration)
621+
? markCompletedTillMigration
622+
: undefined,
625623
})
626624

627625
return reply.send({ message: 'Migrations reset' })
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { DBMigration } from './types'
2+
3+
export function isDBMigrationName(value: unknown): value is keyof typeof DBMigration {
4+
return (
5+
typeof value === 'string' && Object.prototype.hasOwnProperty.call(DBMigration, value)
6+
)
7+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './files'
2+
export * from './guards'
23
export * from './migrate'
34
export * from './types'

src/internal/database/migrations/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,4 @@ export const DBMigration = {
5656
'drop-index-object-level': 54,
5757
'prevent-direct-deletes': 55,
5858
'fix-optimized-search-function': 56,
59-
}
59+
} as const

src/scripts/migrations-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function main() {
3939

4040
const template = `export const DBMigration = {
4141
${migrationsEnum.join('\n')}
42-
}
42+
} as const
4343
`
4444

4545
const destinationPath = path.resolve(

src/test/admin-migrations.test.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
jest.mock('@internal/database/migrations', () => {
2+
const actual = jest.requireActual('@internal/database/migrations')
3+
return {
4+
...actual,
5+
resetMigrationsOnTenants: jest.fn(),
6+
resetMigration: jest.fn(),
7+
runMigrationsOnAllTenants: jest.fn(),
8+
runMigrationsOnTenant: jest.fn(),
9+
}
10+
})
11+
12+
import * as migrations from '@internal/database/migrations'
13+
import { DBMigration } from '@internal/database/migrations'
14+
import { mergeConfig } from '../config'
15+
import { multitenantKnex } from '../internal/database/multitenant-db'
16+
17+
mergeConfig({
18+
pgQueueEnable: true,
19+
})
20+
21+
import { adminApp } from './common'
22+
23+
const tenantId = 'admin-migrations-test-tenant'
24+
25+
const tenantPayload = {
26+
anonKey: 'anon-key',
27+
databaseUrl: 'postgres://tenant-db',
28+
jwtSecret: 'jwt-secret',
29+
serviceKey: 'service-key',
30+
}
31+
32+
describe('Admin migrations routes', () => {
33+
beforeAll(async () => {
34+
await migrations.runMultitenantMigrations()
35+
})
36+
37+
afterEach(async () => {
38+
jest.clearAllMocks()
39+
40+
await adminApp.inject({
41+
method: 'DELETE',
42+
url: `/tenants/${tenantId}`,
43+
headers: {
44+
apikey: process.env.ADMIN_API_KEYS,
45+
},
46+
})
47+
})
48+
49+
afterAll(async () => {
50+
await multitenantKnex.destroy()
51+
})
52+
53+
test('rejects invalid markCompletedTillMigration for fleet reset', async () => {
54+
const resetSpy = jest.mocked(migrations.resetMigrationsOnTenants).mockResolvedValue(undefined)
55+
56+
const response = await adminApp.inject({
57+
method: 'POST',
58+
url: '/migrations/reset/fleet',
59+
payload: {
60+
untilMigration: 'storage-schema' satisfies keyof typeof DBMigration,
61+
markCompletedTillMigration: 'not-a-real-migration',
62+
},
63+
headers: {
64+
apikey: process.env.ADMIN_API_KEYS,
65+
},
66+
})
67+
68+
expect(response.statusCode).toBe(400)
69+
expect(JSON.parse(response.body)).toEqual({ message: 'Invalid migration' })
70+
expect(resetSpy).not.toHaveBeenCalled()
71+
})
72+
73+
test('rejects invalid markCompletedTillMigration for tenant reset', async () => {
74+
await adminApp.inject({
75+
method: 'POST',
76+
url: `/tenants/${tenantId}`,
77+
payload: tenantPayload,
78+
headers: {
79+
apikey: process.env.ADMIN_API_KEYS,
80+
},
81+
})
82+
83+
const resetSpy = jest.mocked(migrations.resetMigration).mockResolvedValue(false)
84+
85+
const response = await adminApp.inject({
86+
method: 'POST',
87+
url: `/tenants/${tenantId}/migrations/reset`,
88+
payload: {
89+
untilMigration: 'storage-schema' satisfies keyof typeof DBMigration,
90+
markCompletedTillMigration: 'not-a-real-migration',
91+
},
92+
headers: {
93+
apikey: process.env.ADMIN_API_KEYS,
94+
},
95+
})
96+
97+
expect(response.statusCode).toBe(400)
98+
expect(JSON.parse(response.body)).toEqual({ message: 'Invalid migration' })
99+
expect(resetSpy).not.toHaveBeenCalled()
100+
})
101+
102+
test('accepts untilMigration that maps to numeric id 0 for fleet reset', async () => {
103+
const resetSpy = jest.mocked(migrations.resetMigrationsOnTenants).mockResolvedValue(undefined)
104+
105+
const response = await adminApp.inject({
106+
method: 'POST',
107+
url: '/migrations/reset/fleet',
108+
payload: {
109+
untilMigration: 'create-migrations-table' satisfies keyof typeof DBMigration,
110+
},
111+
headers: {
112+
apikey: process.env.ADMIN_API_KEYS,
113+
},
114+
})
115+
116+
expect(response.statusCode).toBe(200)
117+
expect(JSON.parse(response.body)).toEqual({ message: 'Migrations scheduled' })
118+
expect(resetSpy).toHaveBeenCalledWith({
119+
till: 'create-migrations-table',
120+
markCompletedTillMigration: undefined,
121+
signal: expect.any(AbortSignal),
122+
})
123+
})
124+
})

0 commit comments

Comments
 (0)