Improve verify-data-integrity

This commit is contained in:
Konstantin Wohlwend 2025-04-27 17:31:28 -07:00
parent 59a08011cc
commit 517e8b2545
3 changed files with 159 additions and 16 deletions

View File

@ -1,11 +1,24 @@
import { PrismaClient } from "@prisma/client";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { deepPlainEquals, filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import fs from "fs";
const prismaClient = new PrismaClient();
const OUTPUT_FILE_PATH = "./verify-data-integrity-output.untracked.json";
type EndpointOutput = {
status: number,
responseJson: any,
};
type OutputData = Record<string, EndpointOutput[]>;
let targetOutputData: OutputData | undefined = undefined;
const currentOutputData: OutputData = {};
async function main() {
console.log();
@ -55,9 +68,30 @@ async function main() {
console.log();
console.log();
const startAt = Math.max(0, +(process.argv[2] || "1") - 1);
const flags = process.argv.slice(3);
const numericArgs = process.argv.filter(arg => arg.match(/^[0-9]+$/)).map(arg => +arg);
const startAt = Math.max(0, (numericArgs[0] ?? 1) - 1);
const count = numericArgs[1] ?? Infinity;
const flags = process.argv.slice(1);
const skipUsers = flags.includes("--skip-users");
const shouldSaveOutput = flags.includes("--save-output");
const shouldVerifyOutput = flags.includes("--verify-output");
if (shouldSaveOutput) {
console.log(`Will save output to ${OUTPUT_FILE_PATH}`);
}
if (shouldVerifyOutput) {
if (!fs.existsSync(OUTPUT_FILE_PATH)) {
throw new Error(`Cannot verify output: ${OUTPUT_FILE_PATH} does not exist`);
}
try {
targetOutputData = JSON.parse(fs.readFileSync(OUTPUT_FILE_PATH, 'utf8'));
console.log(`Loaded previous output data for verification`);
} catch (error) {
throw new Error(`Failed to parse output file: ${error}`);
}
}
const projects = await prismaClient.project.findMany({
select: {
@ -73,10 +107,11 @@ async function main() {
console.log(`Starting at project ${startAt}.`);
}
for (let i = startAt; i < projects.length; i++) {
const endAt = Math.min(startAt + count, projects.length);
for (let i = startAt; i < endAt; i++) {
const projectId = projects[i].id;
await recurse(`[project ${i + 1}/${projects.length}] ${projectId} ${projects[i].displayName}`, async (recurse) => {
const [currentProject, users] = await Promise.all([
await recurse(`[project ${(i + 1) - startAt}/${endAt - startAt}] ${projectId} ${projects[i].displayName}`, async (recurse) => {
const [currentProject, users, projectPermissionDefinitions, teamPermissionDefinitions] = await Promise.all([
expectStatusCode(200, `/api/v1/internal/projects/current`, {
method: "GET",
headers: {
@ -85,7 +120,7 @@ async function main() {
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}),
expectStatusCode(200, `/api/v1/users`, {
expectStatusCode(200, `/api/v1/users?limit=10000`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
@ -93,13 +128,23 @@ async function main() {
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}),
// this endpoint calls the legacy function, so it's a great way to check for config integrity
// once the legacy getProject function is gone, we can remove this too
expectStatusCode(200, `/api/v1/projects/${projectId}/.well-known/jwks.json`, {
expectStatusCode(200, `/api/v1/project-permission-definitions`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}),
expectStatusCode(200, `/api/v1/team-permission-definitions`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}),
]);
if (users.pagination?.next_cursor) throwErr("Users are paginated? Please update the verify-data-integrity.ts script to handle this.");
if (currentProject.user_count !== users.items.length) throwErr("User count mismatch.", {
projectUserCount: currentProject.user_count,
usersUserCount: users.items.length,
@ -109,6 +154,7 @@ async function main() {
for (let j = 0; j < users.items.length; j++) {
const user = users.items[j];
await recurse(`[user ${j + 1}/${users.items.length}] ${user.display_name ?? user.primary_email}`, async (recurse) => {
// get user individually
await expectStatusCode(200, `/api/v1/users/${user.id}`, {
method: "GET",
headers: {
@ -117,12 +163,70 @@ async function main() {
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
});
// list project permissions
const projectPermissions = await expectStatusCode(200, `/api/v1/project-permissions?user_id=${user.id}`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
});
for (const projectPermission of projectPermissions.items) {
if (!projectPermissionDefinitions.items.some((p: any) => p.id === projectPermission.permission_definition_id)) {
throw new StackAssertionError(deindent`
Project permission ${projectPermission.permission_definition_id} not found in project permission definitions.
`);
}
}
// list teams
const teams = await expectStatusCode(200, `/api/v1/teams?user_id=${user.id}`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
});
for (const team of teams.items) {
await recurse(`[team ${team.id}] ${team.name}`, async (recurse) => {
// list team permissions
const teamPermissions = await expectStatusCode(200, `/api/v1/team-permissions?team_id=${team.id}`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
});
for (const teamPermission of teamPermissions.items) {
if (!teamPermissionDefinitions.items.some((p: any) => p.id === teamPermission.id)) {
throw new StackAssertionError(deindent`
Team permission ${teamPermission.id} not found in team permission definitions.
`);
}
}
});
}
});
}
}
});
}
if (targetOutputData && !deepPlainEquals(currentOutputData, targetOutputData)) {
throw new StackAssertionError(deindent`
Output data mismatch between final and target output data.
`);
}
if (shouldSaveOutput) {
fs.writeFileSync(OUTPUT_FILE_PATH, JSON.stringify(currentOutputData, null, 2));
console.log(`Output saved to ${OUTPUT_FILE_PATH}`);
}
console.log();
console.log();
console.log();
@ -157,15 +261,56 @@ async function expectStatusCode(expectedStatusCode: number, endpoint: string, re
...filterUndefined(request.headers ?? {}),
},
});
const responseText = await response.text();
if (response.status !== expectedStatusCode) {
throw new StackAssertionError(deindent`
Expected status code ${expectedStatusCode} but got ${response.status} for ${endpoint}:
${await response.text()}
${responseText}
`, { request, response });
}
const json = await response.json();
return json;
const responseJson = JSON.parse(responseText);
const currentOutput: EndpointOutput = {
status: response.status,
responseJson,
};
appendOutputData(endpoint, currentOutput);
return responseJson;
}
function appendOutputData(endpoint: string, output: EndpointOutput) {
if (!(endpoint in currentOutputData)) {
currentOutputData[endpoint] = [];
}
const newLength = currentOutputData[endpoint].push(output);
if (targetOutputData) {
if (!(endpoint in targetOutputData)) {
throw new StackAssertionError(deindent`
Output data mismatch for endpoint ${endpoint}:
Expected ${endpoint} to be in targetOutputData, but it is not.
`, { endpoint });
}
if (targetOutputData[endpoint].length < newLength) {
throw new StackAssertionError(deindent`
Output data mismatch for endpoint ${endpoint}:
Expected ${targetOutputData[endpoint].length} outputs but got at least ${newLength}.
`, { endpoint });
}
if (!(deepPlainEquals(targetOutputData[endpoint][newLength - 1], output))) {
throw new StackAssertionError(deindent`
Output data mismatch for endpoint ${endpoint}:
Expected output[${JSON.stringify(endpoint)}][${newLength - 1}] to be:
${JSON.stringify(output, null, 2)}
but got:
${JSON.stringify(targetOutputData[endpoint][newLength - 1], null, 2)}.
`, { endpoint });
}
}
}
let lastProgress = performance.now() - 9999999999;

View File

@ -1,6 +1,5 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],

View File

@ -1,6 +1,5 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",