Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Schema 8 #228

Draft
wants to merge 19 commits into
base: next
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 9 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
23 changes: 15 additions & 8 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,18 @@ const config = {
'next/core-web-vitals',
'prettier',
],
ignorePatterns: ['node_modules', '*.stories.*', '*.test.*', 'public', '.eslintrc.cjs',],
ignorePatterns: [
'node_modules',
'*.stories.*',
'*.test.*',
'public',
'.eslintrc.cjs',
'lib/protocol-validation', // TODO: remove this, and fix the errors
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not merge without removing this

],
rules: {
"@next/next/no-img-element": "off",
"import/no-anonymous-default-export": "off",
"@typescript-eslint/consistent-type-definitions": ['error', 'type'],
'@next/next/no-img-element': 'off',
'import/no-anonymous-default-export': 'off',
'@typescript-eslint/consistent-type-definitions': ['error', 'type'],
'no-process-env': 'error',
'no-console': 'error',
'@typescript-eslint/consistent-type-imports': [
Expand All @@ -46,11 +53,11 @@ const config = {
argsIgnorePattern: '^_',
},
],
"@typescript-eslint/no-misused-promises": [
"error",
'@typescript-eslint/no-misused-promises': [
'error',
{
"checksVoidReturn": false
}
checksVoidReturn: false,
},
],
'no-unreachable': 'error',
},
Expand Down
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"WillLuke.nextjs.addTypesOnSave": true,
"WillLuke.nextjs.hasPrompted": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "always"
"source.organizeImports": "always",
"source.fixAll": "always"
}
}
4 changes: 2 additions & 2 deletions app/(interview)/interview/[interviewId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { notFound, redirect } from 'next/navigation';
import { syncInterview } from '~/actions/interviews';
import FeedbackBanner from '~/components/Feedback/FeedbackBanner';
import { getAppSetting } from '~/queries/appSettings';
import { getInterviewById } from '~/queries/interviews';
import { TESTING_getInterviewById } from '~/queries/interviews';
import { getServerSession } from '~/utils/auth';
import InterviewShell from '../_components/InterviewShell';

Expand All @@ -18,7 +18,7 @@ export default async function Page({
return 'No interview id found';
}

const interview = await getInterviewById(interviewId);
const interview = await TESTING_getInterviewById(interviewId);
const session = await getServerSession();

// If the interview is not found, redirect to the 404 page
Expand Down
4 changes: 2 additions & 2 deletions hooks/useProtocolImport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const useProtocolImport = () => {
return;
}

const { validateProtocol } = await import('@codaco/protocol-validation');
const { validateProtocol } = await import('~/lib/protocol-validation');

const validationResult = await validateProtocol(protocolJson);

Expand Down Expand Up @@ -130,7 +130,7 @@ export const useProtocolImport = () => {
...validationResult.logicErrors,
].map((validationError, i) => (
<li className="flex capitalize" key={i}>
<XCircle className="mr-2 h-4 w-4 text-destructive" />
<XCircle className="text-destructive mr-2 h-4 w-4" />
<span>
{validationError.message}{' '}
<span className="text-xs italic">
Expand Down
11 changes: 10 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,16 @@
"lib/interviewer/hooks/forceSimulation.worker.js",
"lib/ui/components/Sprites/ExportSprite.js",
"utils/auth.ts",
"load-test.js"
"load-test.js",
"lib/protocol-validation/schemas/compiled/index.js",
"lib/protocol-validation/schemas/compiled/1.js",
"lib/protocol-validation/schemas/compiled/2.js",
"lib/protocol-validation/schemas/compiled/3.js",
"lib/protocol-validation/schemas/compiled/4.js",
"lib/protocol-validation/schemas/compiled/5.js",
"lib/protocol-validation/schemas/compiled/6.js",
"lib/protocol-validation/schemas/compiled/7.js",
"lib/protocol-validation/schemas/compiled/8.js"
],
"ignoreDependencies": [
"server-only",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/* eslint-env jest */

import { ncUUIDProperty } from '@codaco/shared-consts';
import { DOMParser } from '@xmldom/xmldom';
import { beforeEach, describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -100,7 +98,10 @@ describe('buildGraphML', () => {
});

it('omits networkCanvasUUID data element when network.codebook.ego is empty', () => {
const processedNetworks = processMockNetworks([mockNetwork, mockNetwork2]);
const processedNetworks = processMockNetworks([
mockNetwork,
mockNetwork2,
]);
const protocolNetwork = processedNetworks['protocol-uid-1'][0];
const { ego, ...egolessCodebook } = codebook;
const egolessNetwork = { ...protocolNetwork, ego: {} };
Expand Down
128 changes: 128 additions & 0 deletions lib/protocol-validation/__tests__/test-protocols.test.ts
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be implemented or removed before merging

Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { execSync } from 'child_process';
import { createDecipheriv } from 'crypto';
import { readFile, writeFile } from 'fs/promises';
import type Zip from 'jszip';
import JSZip from 'jszip';
import { join } from 'path';
import { describe, it } from 'vitest';
import { validateProtocol } from '../index';

// Utility functions for encryption handling
const decryptFile = async (
encryptedBuffer: Buffer,
key: string,
iv: string,
): Promise<Buffer> => {
const decipher = createDecipheriv(
'aes-256-cbc',
Buffer.from(key, 'hex'),
Buffer.from(iv, 'hex'),
);
const decrypted = Buffer.concat([
decipher.update(encryptedBuffer),
decipher.final(),
]);
return decrypted;
};

const downloadAndDecryptProtocols = async (tempDir: string): Promise<void> => {
const encryptionKey = process.env.PROTOCOL_ENCRYPTION_KEY;
const encryptionIv = process.env.PROTOCOL_ENCRYPTION_IV;

if (!encryptionKey || !encryptionIv) {
throw new Error(
'Encryption key and IV must be set in environment variables',
);
}

// Download encrypted protocols from GitHub LFS
// Replace with your actual GitHub repository URL
const githubUrl =
'https://github.com/YOUR_ORG/YOUR_REPO/raw/main/test-protocols.tar.gz.enc';

try {
console.log('Downloading encrypted protocols...');
execSync(
`curl -L -o ${join(tempDir, 'protocols.tar.gz.enc')} ${githubUrl}`,
);

// Decrypt the file
console.log('Decrypting protocols...');
const encryptedData = await readFile(join(tempDir, 'protocols.tar.gz.enc'));
const decryptedData = await decryptFile(
encryptedData,
encryptionKey,
encryptionIv,
);
await writeFile(join(tempDir, 'protocols.tar.gz'), decryptedData);

// Extract the tar.gz file
console.log('Extracting protocols...');
execSync(`tar -xzf ${join(tempDir, 'protocols.tar.gz')} -C ${tempDir}`);
} catch (error) {
console.error('Error preparing protocols:', error);
throw error;
}
};

export const getProtocolJsonAsObject = async (zip: Zip) => {
const protocolString = await zip.file('protocol.json')?.async('string');

if (!protocolString) {
throw new Error('protocol.json not found in zip');
}

const protocol = await JSON.parse(protocolString);
return protocol;
};

const extractAndValidate = async (protocolPath: string) => {
const buffer = await readFile(protocolPath);
const zip = await JSZip.loadAsync(buffer);
const protocol = await getProtocolJsonAsObject(zip);

let schemaVersion = undefined;
if (!protocol.schemaVersion || protocol.schemaVersion === '1.0.0') {
console.log('schemaVersion is missing or "1.0.0" for', protocolPath);
schemaVersion = 1;
}

return await validateProtocol(protocol, schemaVersion);
};

// Create temporary directory for test protocols
let tempDir: string;

describe('Test protocols', () => {
it.todo('should validate all test protocols');

// beforeAll(async () => {
// // Create temporary directory
// tempDir = mkdtempSync(join(tmpdir(), 'test-protocols-'));

// // Skip download in CI if protocols are already present
// if (process.env.CI && process.env.SKIP_PROTOCOL_DOWNLOAD) {
// console.log('Skipping protocol download in CI');
// return;
// }

// await downloadAndDecryptProtocols(tempDir);
// });

// afterAll(() => {
// // Clean up temporary directory
// rmSync(tempDir, { recursive: true, force: true });
// });

// it.each(readdirSync(tempDir).filter((file) => file.endsWith('.netcanvas')))(
// '%s',
// async (protocol) => {
// const protocolPath = join(tempDir, protocol);
// const result = await extractAndValidate(protocolPath);

// expect(result.isValid).toBe(true);
// expect(result.schemaErrors).toEqual([]);
// expect(result.logicErrors).toEqual([]);
// },
// );
});
49 changes: 49 additions & 0 deletions lib/protocol-validation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { type Protocol } from '@codaco/shared-consts';
import { ensureError } from '~/utils/ensureError';
import { validateLogic } from './validation/validateLogic';
import { validateSchema } from './validation/validateSchema';

export type ValidationError = {
path: string;
message: string;
};

type ValidationResult = {
isValid: boolean;
schemaErrors: ValidationError[];
logicErrors: ValidationError[];
schemaVersion: number;
schemaForced: boolean;
};

const validateProtocol = async (
protocol: Protocol,
forceSchemaVersion?: number,
) => {
if (protocol === undefined) {
throw new Error('Protocol is undefined');
}

try {
const { hasErrors: hasSchemaErrors, errors: schemaErrors } =
await validateSchema(protocol, forceSchemaVersion);
const { hasErrors: hasLogicErrors, errors: logicErrors } =
validateLogic(protocol);

return {
isValid: !hasSchemaErrors && !hasLogicErrors,
schemaErrors,
logicErrors,
schemaVersion: protocol.schemaVersion,
schemaForced: forceSchemaVersion !== undefined,
} as ValidationResult;
} catch (e) {
const error = ensureError(e);

throw new Error(
`Protocol validation failed due to an internal error: ${error.message}`,
);
}
};

export { validateProtocol, };
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import getMigrationPath from '../getMigrationPath';

/**
* Migrations are run in the order that they are defined relative to one another
* e.g. 1 -> 3, will run 1 -> 2 -> 3
*/

describe('getMigrationPath', () => {
it('gets the correct migration path for a protocol', () => {
const migrationPath = getMigrationPath(1, 4);

expect(migrationPath.length).toBe(3);

expect(migrationPath).toEqual(
expect.arrayContaining([
expect.objectContaining({ version: 2 }),
expect.objectContaining({ version: 3 }),
expect.objectContaining({ version: 4 }),
]),
);
});
});
Loading