diff --git a/hub-js/package-lock.json b/hub-js/package-lock.json index 13a7e6d61..d5b7a10cf 100644 --- a/hub-js/package-lock.json +++ b/hub-js/package-lock.json @@ -17,9 +17,11 @@ "long": "^5.2.0", "graphql-tools": "^8.1.0", "neo4j-driver": "^4.3.0", + "ajv-formats": "^2.1.1", "apollo-server-express": "^3.6.2", "graphql": "^15.4.0", "@godaddy/terminus": "^4.8.0", + "ajv": "^8.10.0", "nice-grpc": "^1.0.6", "async-mutex": "^0.3.2", "express": "^4.17.0", @@ -1401,10 +1403,9 @@ "dev": true }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "node_modules/relay-compiler/node_modules/yargs": { "version": "15.4.1", @@ -1677,6 +1678,22 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@protobufjs/codegen": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", @@ -2987,14 +3004,13 @@ "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.10.0.tgz", + "integrity": "sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw==", "dependencies": { "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", "uri-js": "^4.2.2" }, "funding": { @@ -3242,6 +3258,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -3298,8 +3330,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/error-ex": { "version": "1.3.2", @@ -3428,7 +3459,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true, "engines": { "node": ">=6" } @@ -4082,6 +4112,12 @@ "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/@graphql-tools/links/node_modules/@graphql-tools/utils/node_modules/tslib": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", @@ -4092,6 +4128,14 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4410,7 +4454,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "dependencies": { "punycode": "^2.1.0" } @@ -5579,6 +5622,22 @@ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=" }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/@ardatan/aggregate-error": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@ardatan/aggregate-error/-/aggregate-error-0.0.6.tgz", @@ -6018,6 +6077,12 @@ "graphql": "^14.0.0 || ^15.0.0" } }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, "node_modules/@babel/helpers/node_modules/@babel/types": { "version": "7.17.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz", @@ -8670,11 +8735,29 @@ "strip-json-comments": "^3.1.1" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "ignore": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz", "integrity": "sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==", "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, @@ -9930,17 +10013,24 @@ } }, "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.10.0.tgz", + "integrity": "sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw==", "requires": { "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "requires": { + "ajv": "^8.0.0" + } + }, "ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -10835,6 +10925,18 @@ "v8-compile-cache": "^2.0.3" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "eslint-scope": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", @@ -10850,6 +10952,12 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true } } }, @@ -11035,8 +11143,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.11", @@ -11633,10 +11740,9 @@ "dev": true }, "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, "json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -12564,8 +12670,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, "qs": { "version": "6.9.7", @@ -12786,6 +12891,11 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, "require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -13306,7 +13416,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "requires": { "punycode": "^2.1.0" } diff --git a/hub-js/package.json b/hub-js/package.json index 86b9a6a06..497a88504 100644 --- a/hub-js/package.json +++ b/hub-js/package.json @@ -22,6 +22,8 @@ "@godaddy/terminus": "^4.8.0", "@grpc/grpc-js": "^1.3.1", "@types/lodash": "^4.14.179", + "ajv": "^8.10.0", + "ajv-formats": "^2.1.1", "apollo-server-express": "^3.6.2", "async-mutex": "^0.3.2", "express": "^4.17.0", diff --git a/hub-js/src/local/storage/backend-schema.ts b/hub-js/src/local/storage/backend-schema.ts new file mode 100644 index 000000000..5b888fb7f --- /dev/null +++ b/hub-js/src/local/storage/backend-schema.ts @@ -0,0 +1,25 @@ +import { JSONSchemaType } from "ajv"; + +export interface StorageTypeInstanceSpec { + url: string; + acceptValue: boolean; + contextSchema: string | null; +} + +export const StorageTypeInstanceSpecSchema: JSONSchemaType = + { + $schema: "http://json-schema.org/draft-07/schema", + title: "The Storage TypeInstance's spec field schema", + type: "object", + properties: { + url: { + $id: "#/properties/url", + type: "string", + format: "uri", + }, + acceptValue: { type: "boolean" }, + contextSchema: { type: "string", nullable: true }, + }, + required: ["url", "acceptValue"], + additionalProperties: true, + }; diff --git a/hub-js/src/local/storage/service.ts b/hub-js/src/local/storage/service.ts index 139e086a5..42fa1fb5b 100644 --- a/hub-js/src/local/storage/service.ts +++ b/hub-js/src/local/storage/service.ts @@ -7,10 +7,18 @@ import { OnUpdateRequest, StorageBackendDefinition, } from "../../generated/grpc/storage_backend"; -import { createChannel, createClient, Client } from "nice-grpc"; +import { Client, createChannel, createClient } from "nice-grpc"; import { Driver } from "neo4j-driver"; import { TypeInstanceBackendInput } from "../types/type-instance"; import { logger } from "../../logger"; +import Ajv from "ajv"; +import addFormats from "ajv-formats"; +import { + StorageTypeInstanceSpec, + StorageTypeInstanceSpecSchema, +} from "./backend-schema"; +import { JSONSchemaType } from "ajv/lib/types/json-schema"; +import { TextEncoder } from "util"; // TODO(https://github.com/capactio/capact/issues/634): // Represents the fake storage backend URL that should be ignored @@ -20,8 +28,24 @@ export const FAKE_TEST_URL = "e2e-test-backend-mock-url:50051"; type StorageClient = Client; -export interface StorageInstanceDetails { - url: string; +interface BackendContainer { + client: StorageClient; + validateSpec: ValidateBackendSpec; +} + +interface ValidateBackendSpec { + backendId: string; + contextSchema: JSONSchemaType | undefined; + acceptValue: boolean; +} + +type ValidateInput = GetInput | UpdateInput | DeleteInput | StoreInput; + +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } } export interface StoreInput { @@ -78,12 +102,16 @@ export interface UpdatedContexts { } export default class DelegatedStorageService { - private registeredClients: Map; - private dbDriver: Driver; + private registeredClients: Map; + private readonly dbDriver: Driver; + private readonly ajv: Ajv; constructor(dbDriver: Driver) { - this.registeredClients = new Map(); + this.registeredClients = new Map(); this.dbDriver = dbDriver; + + this.ajv = new Ajv({ allErrors: true }); + addFormats(this.ajv); } /** @@ -93,8 +121,6 @@ export default class DelegatedStorageService { * @param inputs - Describes what should be stored. * @returns The update backend's context. If there was no update, it's undefined. * - * TODO(https://github.com/capactio/capact/issues/656): validate if `input.value` is allowed by backend (`backend.acceptValue`) - * TODO(https://github.com/capactio/capact/issues/656): validate `input.backend.context` against `backend.contextSchema`. */ async Store(...inputs: StoreInput[]): Promise { let mapping: UpdatedContexts = {}; @@ -104,18 +130,26 @@ export default class DelegatedStorageService { typeInstanceId: input.typeInstance.id, backendId: input.backend.id, }); - const cli = await this.getClient(input.backend.id); - if (!cli) { + const backend = await this.getBackendContainer(input.backend.id); + if (!backend?.client) { // TODO: remove after using a real backend in e2e tests. continue; } + const validateErr = this.validateInput(input, backend.validateSpec); + if (validateErr) { + throw Error( + `External backend "${input.backend.id}": ${validateErr.message}` + ); + } + const req: OnCreateRequest = { typeInstanceId: input.typeInstance.id, - value: this.encode(input.typeInstance.value), - context: this.encode(input.backend.context), + value: DelegatedStorageService.encode(input.typeInstance.value), + context: DelegatedStorageService.encode(input.backend.context), }; - const res = await cli.onCreate(req); + + const res = await backend.client.onCreate(req); if (!res.context) { continue; @@ -137,8 +171,6 @@ export default class DelegatedStorageService { * * @param inputs - Describes what should be updated. * - * TODO(https://github.com/capactio/capact/issues/656): validate if `input.value` is allowed by backend (`backend.acceptValue`) - * TODO(https://github.com/capactio/capact/issues/656): validate `input.backend.context` against `backend.contextSchema`. */ async Update(...inputs: UpdateInput[]) { for (const input of inputs) { @@ -146,21 +178,28 @@ export default class DelegatedStorageService { typeInstanceId: input.typeInstance.id, backendId: input.backend.id, }); - const cli = await this.getClient(input.backend.id); - if (!cli) { + const backend = await this.getBackendContainer(input.backend.id); + if (!backend?.client) { // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. continue; } + const validateErr = this.validateInput(input, backend.validateSpec); + if (validateErr) { + throw Error( + `External backend "${input.backend.id}": ${validateErr.message}` + ); + } + const req: OnUpdateRequest = { typeInstanceId: input.typeInstance.id, newResourceVersion: input.typeInstance.newResourceVersion, - newValue: this.encode(input.typeInstance.newValue), - context: this.encode(input.backend.context), + newValue: DelegatedStorageService.encode(input.typeInstance.newValue), + context: DelegatedStorageService.encode(input.backend.context), ownerId: input.typeInstance.ownerID, }; - await cli.onUpdate(req); + await backend.client.onUpdate(req); } } @@ -180,8 +219,8 @@ export default class DelegatedStorageService { typeInstanceId: input.typeInstance.id, backendId: input.backend.id, }); - const cli = await this.getClient(input.backend.id); - if (!cli) { + const backend = await this.getBackendContainer(input.backend.id); + if (!backend?.client) { // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. result = { ...result, @@ -192,12 +231,19 @@ export default class DelegatedStorageService { continue; } + const validateErr = this.validateInput(input, backend.validateSpec); + if (validateErr) { + throw Error( + `External backend "${input.backend.id}": ${validateErr.message}` + ); + } + const req: GetValueRequest = { typeInstanceId: input.typeInstance.id, resourceVersion: input.typeInstance.resourceVersion, - context: this.encode(input.backend.context), + context: DelegatedStorageService.encode(input.backend.context), }; - const res = await cli.getValue(req); + const res = await backend.client.getValue(req); if (!res.value) { throw Error( @@ -227,18 +273,24 @@ export default class DelegatedStorageService { typeInstanceId: input.typeInstance.id, backendId: input.backend.id, }); - const cli = await this.getClient(input.backend.id); - if (!cli) { + const backend = await this.getBackendContainer(input.backend.id); + if (!backend?.client) { // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. continue; } + const validateErr = this.validateInput(input, backend.validateSpec); + if (validateErr) { + throw Error( + `External backend "${input.backend.id}": ${validateErr.message}` + ); + } const req: OnDeleteRequest = { typeInstanceId: input.typeInstance.id, - context: this.encode(input.backend.context), + context: DelegatedStorageService.encode(input.backend.context), ownerId: input.typeInstance.ownerID, }; - await cli.onDelete(req); + await backend.client.onDelete(req); } } @@ -254,18 +306,25 @@ export default class DelegatedStorageService { typeInstanceId: input.typeInstance.id, backendId: input.backend.id, }); - const cli = await this.getClient(input.backend.id); - if (!cli) { + const backend = await this.getBackendContainer(input.backend.id); + if (!backend?.client) { // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. continue; } + const validateErr = this.validateInput(input, backend.validateSpec); + if (validateErr) { + throw Error( + `External backend "${input.backend.id}": ${validateErr.message}` + ); + } + const req: OnLockRequest = { typeInstanceId: input.typeInstance.id, lockedBy: input.typeInstance.lockedBy, - context: this.encode(input.backend.context), + context: DelegatedStorageService.encode(input.backend.context), }; - await cli.onLock(req); + await backend.client.onLock(req); } } @@ -281,23 +340,30 @@ export default class DelegatedStorageService { typeInstanceId: input.typeInstance.id, backendId: input.backend.id, }); - const cli = await this.getClient(input.backend.id); - if (!cli) { + const backend = await this.getBackendContainer(input.backend.id); + if (!backend?.client) { // TODO(https://github.com/capactio/capact/issues/634): remove after using a real backend in e2e tests. continue; } + const validateErr = this.validateInput(input, backend.validateSpec); + if (validateErr) { + throw Error( + `External backend "${input.backend.id}": ${validateErr.message}` + ); + } + const req: OnUnlockRequest = { typeInstanceId: input.typeInstance.id, - context: this.encode(input.backend.context), + context: DelegatedStorageService.encode(input.backend.context), }; - await cli.onUnlock(req); + await backend.client.onUnlock(req); } } private async storageInstanceDetailsFetcher( id: string - ): Promise { + ): Promise { const sess = this.dbDriver.session(); try { const fetchRevisionResult = await sess.run( @@ -326,7 +392,12 @@ export default class DelegatedStorageService { } const record = fetchRevisionResult.records[0]; - return record.get("value"); // TODO(https://github.com/capactio/capact/issues/656): validate against Storage JSON Schema. + + const storageSpec: StorageTypeInstanceSpec = record.get("value"); + + this.validateStorageSpecValue(storageSpec); + + return storageSpec; } catch (e) { const err = e as Error; throw new Error( @@ -337,10 +408,12 @@ export default class DelegatedStorageService { } } - private async getClient(id: string): Promise { + private async getBackendContainer( + id: string + ): Promise { if (!this.registeredClients.has(id)) { - const { url } = await this.storageInstanceDetailsFetcher(id); - if (url === FAKE_TEST_URL) { + const spec = await this.storageInstanceDetailsFetcher(id); + if (spec.url === FAKE_TEST_URL) { logger.debug( "Skipping a real call as backend was classified as a fake one" ); @@ -348,16 +421,34 @@ export default class DelegatedStorageService { return undefined; } - logger.debug("Initialize gRPC client", { + logger.debug("Initialize gRPC BackendContainer", { backend: id, - url, + url: spec.url, }); - const channel = createChannel(url); + + let contextSchema; + if (spec.contextSchema) { + const out = DelegatedStorageService.parseToObject(spec.contextSchema); + if (out.error) { + throw Error( + `failed to process the TypeInstance's backend "${id}": invalid spec.context: ${out.error.message}` + ); + } + contextSchema = out.parsed as JSONSchemaType; + } + const channel = createChannel(spec.url); const client: StorageClient = createClient( StorageBackendDefinition, channel ); - this.registeredClients.set(id, client); + + const storageSpec = { + backendId: id, + contextSchema, + acceptValue: spec.acceptValue, + }; + + this.registeredClients.set(id, { client, validateSpec: storageSpec }); } return this.registeredClients.get(id); @@ -375,4 +466,83 @@ export default class DelegatedStorageService { DelegatedStorageService.convertToJSONIfObject(val) ); } + + private validateStorageSpecValue(storageSpec: StorageTypeInstanceSpec) { + const validate = this.ajv.compile(StorageTypeInstanceSpecSchema); + + if (validate(storageSpec)) { + return; + } + + throw new Error( + this.ajv.errorsText(validate.errors, { dataVar: "spec.value" }) + ); + } + + private static encode(val: unknown) { + return new TextEncoder().encode( + DelegatedStorageService.convertToJSONIfObject(val) + ); + } + + private static normalizeInput( + input: GetInput | UpdateInput | DeleteInput | StoreInput + ) { + const out: { context?: unknown; value?: unknown } = { + context: input.backend.context, + value: undefined, + }; + + if ("value" in input.typeInstance) { + out.value = input.typeInstance.value; + } + if ("newValue" in input.typeInstance) { + out.value = input.typeInstance.newValue; + } + return out; + } + + private validateInput( + input: ValidateInput, + storageSpec: ValidateBackendSpec + ): ValidationError | undefined { + const { value, context } = DelegatedStorageService.normalizeInput(input); + + if (!storageSpec.acceptValue && value) { + return new ValidationError("input value not allowed"); + } + + if (context) { + if (storageSpec.contextSchema === undefined) { + return new ValidationError("input context not allowed"); + } + + const validate = this.ajv.compile(storageSpec.contextSchema); + if (!validate(context)) { + const msg = this.ajv.errorsText(validate.errors, { + dataVar: "context", + }); + return new ValidationError(`invalid input: ${msg}`); + } + } + + return undefined; + } + + private static parseToObject(input: string): { + error?: Error; + parsed: unknown; + } { + try { + return { + parsed: JSON.parse(input), + }; + } catch (e) { + const err = e as Error; + return { + parsed: {}, + error: err, + }; + } + } } diff --git a/test/e2e/hub_test.go b/test/e2e/hub_test.go index 7c011dd61..42b5d0c10 100644 --- a/test/e2e/hub_test.go +++ b/test/e2e/hub_test.go @@ -6,6 +6,7 @@ package e2e import ( "context" "fmt" + "regexp" "strings" "capact.io/capact/internal/ptr" @@ -18,6 +19,7 @@ import ( "github.com/MakeNowJust/heredoc" prmt "github.com/gitchander/permutation" . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" . "github.com/onsi/gomega" ) @@ -641,9 +643,194 @@ var _ = Describe("GraphQL API", func() { }) Expect(err).NotTo(HaveOccurred()) }) + + DescribeTable("External Storage Input Validation", + func(storageSpec interface{}, instanceValue, instanceBackendCtx interface{}, expErrMsg string) { + localCli := getHubGraphQLClient() + externalStorageID, cleanup := registerExternalStorage(ctx, localCli, storageSpec) + defer cleanup() + + // when + _, err := localCli.CreateTypeInstance(ctx, &gqllocalapi.CreateTypeInstanceInput{ + TypeRef: &gqllocalapi.TypeInstanceTypeReferenceInput{ + Path: "cap.type.testing:0.1.0", + Revision: "0.1.0", + }, + Value: instanceValue, + Backend: &gqllocalapi.TypeInstanceBackendInput{ + ID: externalStorageID, + Context: instanceBackendCtx, + }, + }) + + regex := regexp.MustCompile(`\w{8}-\w{4}-\w{4}-\w{4}-\w{12}`) + gotErr := regex.ReplaceAllString(err.Error(), "MOCKED_ID") + + // then + Expect(gotErr).To(Equal(expErrMsg)) + }, + Entry("Should rejected value", + StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), + }, + map[string]interface{}{ + "name": "Luke Skywalker", + }, + nil, + heredoc.Doc(` + while executing mutation to create TypeInstance: All attempts fail: + #1: graphql: failed to create TypeInstance: failed to create the TypeInstances: 2 error occurred: + * Error: External backend "MOCKED_ID": input value not allowed + * Error: rollback externally stored values: External backend "MOCKED_ID": input value not allowed`), + ), + Entry("Should rejected context", + StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), + }, + nil, + map[string]interface{}{ + "name": "Luke Skywalker", + }, + heredoc.Doc(` + while executing mutation to create TypeInstance: All attempts fail: + #1: graphql: failed to create TypeInstance: failed to create the TypeInstances: 2 error occurred: + * Error: External backend "MOCKED_ID": input context not allowed + * Error: rollback externally stored values: External backend "MOCKED_ID": input context not allowed`), + ), + Entry("Should return error that context is not an object", + StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), + ContextSchema: ptr.String(heredoc.Doc(` + { + "$id": "#/properties/contextSchema", + "type": "object", + "properties": { + "provider": { + "$id": "#/properties/contextSchema/properties/name", + "type": "string" + } + }, + "additionalProperties": false + }`)), + }, + nil, + "Luke Skywalker", + heredoc.Doc(` + while executing mutation to create TypeInstance: All attempts fail: + #1: graphql: failed to create TypeInstance: failed to create the TypeInstances: 2 error occurred: + * Error: External backend "MOCKED_ID": invalid input: context must be object + * Error: rollback externally stored values: External backend "MOCKED_ID": invalid input: context must be object`)), + Entry("Should return validation error for context", + StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), + ContextSchema: ptr.String(heredoc.Doc(` + { + "$id": "#/properties/contextSchema", + "type": "object", + "properties": { + "provider": { + "$id": "#/properties/contextSchema/properties/name", + "type": "string" + } + }, + "additionalProperties": false + }`)), + }, + nil, + map[string]interface{}{ + "provider": true, + }, + heredoc.Doc(` + while executing mutation to create TypeInstance: All attempts fail: + #1: graphql: failed to create TypeInstance: failed to create the TypeInstances: 2 error occurred: + * Error: External backend "MOCKED_ID": invalid input: context/provider must be string + * Error: rollback externally stored values: External backend "MOCKED_ID": invalid input: context/provider must be string`)), + Entry("Should reject value and context", + StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), + }, + map[string]interface{}{ + "name": "Luke Skywalker", + }, + map[string]interface{}{ + "name": "Luke Skywalker", + }, + // currently, it's an early return + heredoc.Doc(` + while executing mutation to create TypeInstance: All attempts fail: + #1: graphql: failed to create TypeInstance: failed to create the TypeInstances: 2 error occurred: + * Error: External backend "MOCKED_ID": input value not allowed + * Error: rollback externally stored values: External backend "MOCKED_ID": input value not allowed`)), + Entry("Should reject usage of backend without URL field", + StorageSpec{ + AcceptValue: ptr.Bool(false), + }, + map[string]interface{}{ + "name": "Luke Skywalker", + }, + nil, + heredoc.Doc(` + while executing mutation to create TypeInstance: All attempts fail: + #1: graphql: failed to create TypeInstance: failed to create the TypeInstances: 2 error occurred: + * Error: failed to resolve the TypeInstance's backend "MOCKED_ID": spec.value must have required property 'url' + * Error: rollback externally stored values: failed to resolve the TypeInstance's backend "MOCKED_ID": spec.value must have required property 'url'`)), + Entry("Should reject usage of backend without AcceptValue field", + StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + }, + map[string]interface{}{ + "name": "Luke Skywalker", + }, + nil, + heredoc.Doc(` + while executing mutation to create TypeInstance: All attempts fail: + #1: graphql: failed to create TypeInstance: failed to create the TypeInstances: 2 error occurred: + * Error: failed to resolve the TypeInstance's backend "MOCKED_ID": spec.value must have required property 'acceptValue' + * Error: rollback externally stored values: failed to resolve the TypeInstance's backend "MOCKED_ID": spec.value must have required property 'acceptValue'`)), + Entry("Should reject usage of backend without URL and AcceptValue fields", map[string]interface{}{ + "other-data": true, + }, + map[string]interface{}{ + "name": "Luke Skywalker", + }, + nil, + heredoc.Doc(` + while executing mutation to create TypeInstance: All attempts fail: + #1: graphql: failed to create TypeInstance: failed to create the TypeInstances: 2 error occurred: + * Error: failed to resolve the TypeInstance's backend "MOCKED_ID": spec.value must have required property 'url', spec.value must have required property 'acceptValue' + * Error: rollback externally stored values: failed to resolve the TypeInstance's backend "MOCKED_ID": spec.value must have required property 'url', spec.value must have required property 'acceptValue'`)), + Entry("Should reject usage of backend with wrong context schema", + StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), + ContextSchema: ptr.String(heredoc.Doc(` + yaml: true`)), + }, + map[string]interface{}{ + "name": "Luke Skywalker", + }, + nil, + heredoc.Doc(` + while executing mutation to create TypeInstance: All attempts fail: + #1: graphql: failed to create TypeInstance: failed to create the TypeInstances: 2 error occurred: + * Error: failed to process the TypeInstance's backend "MOCKED_ID": invalid spec.context: Unexpected token y in JSON at position 0 + * Error: rollback externally stored values: failed to process the TypeInstance's backend "MOCKED_ID": invalid spec.context: Unexpected token y in JSON at position 0`)), + ) + }) }) +type StorageSpec struct { + URL *string `json:"url,omitempty"` + AcceptValue *bool `json:"acceptValue,omitempty"` + ContextSchema *string `json:"contextSchema,omitempty"` +} + func includes(ids []string, expID string) bool { for _, i := range ids { if i == expID { @@ -654,6 +841,24 @@ func includes(ids []string, expID string) bool { return false } +func registerExternalStorage(ctx context.Context, cli *hubclient.Client, value interface{}) (string, func()) { + storage := &gqllocalapi.CreateTypeInstanceInput{ + TypeRef: &gqllocalapi.TypeInstanceTypeReferenceInput{ + Path: "cap.type.example.filesystem.storage", + Revision: "0.1.0", + }, + Value: value, + } + + externalStorageID, err := cli.CreateTypeInstance(ctx, storage) + Expect(err).NotTo(HaveOccurred()) + Expect(externalStorageID).NotTo(BeEmpty()) + + return externalStorageID, func() { + _ = cli.DeleteTypeInstance(ctx, externalStorageID) + } +} + func typeInstance(ver string) *gqllocalapi.CreateTypeInstanceInput { return &gqllocalapi.CreateTypeInstanceInput{ TypeRef: &gqllocalapi.TypeInstanceTypeReferenceInput{