From cf51c75069fcbb12fbe93937ceca0193164c7af9 Mon Sep 17 00:00:00 2001 From: Mateusz Szostok Date: Wed, 9 Mar 2022 23:00:37 +0100 Subject: [PATCH 1/3] Middleware --- hub-js/package-lock.json | 167 +++++++-- hub-js/package.json | 2 + hub-js/src/local/storage/backend-schema.ts | 25 ++ hub-js/src/local/storage/service.ts | 168 +++++++-- pkg/hub/client/local/client_test.go | 392 +++++++++++++++++++++ 5 files changed, 698 insertions(+), 56 deletions(-) create mode 100644 hub-js/src/local/storage/backend-schema.ts create mode 100644 pkg/hub/client/local/client_test.go 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..46d51f2ae 100644 --- a/hub-js/src/local/storage/service.ts +++ b/hub-js/src/local/storage/service.ts @@ -7,10 +7,25 @@ import { OnUpdateRequest, StorageBackendDefinition, } from "../../generated/grpc/storage_backend"; -import { createChannel, createClient, Client } from "nice-grpc"; +import { + Client, + ClientError, + ClientMiddleware, + createChannel, + createClientFactory, + Status, +} 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 { TextDecoder, TextEncoder } from "util"; // TODO(https://github.com/capactio/capact/issues/634): // Represents the fake storage backend URL that should be ignored @@ -18,11 +33,7 @@ import { logger } from "../../logger"; // It should be removed after a real backend is used in `test/e2e/action_test.go` scenarios. export const FAKE_TEST_URL = "e2e-test-backend-mock-url:50051"; -type StorageClient = Client; - -export interface StorageInstanceDetails { - url: string; -} +type StorageClient = Client; export interface StoreInput { backend: TypeInstanceBackendInput; @@ -79,11 +90,15 @@ export interface UpdatedContexts { export default class DelegatedStorageService { private registeredClients: Map; - private dbDriver: Driver; + private readonly dbDriver: Driver; + private readonly ajv: Ajv; constructor(dbDriver: Driver) { this.registeredClients = new Map(); this.dbDriver = dbDriver; + + this.ajv = new Ajv({ allErrors: true }); + addFormats(this.ajv); } /** @@ -93,8 +108,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 = {}; @@ -112,8 +125,8 @@ export default class DelegatedStorageService { 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); @@ -137,8 +150,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) { @@ -155,8 +166,8 @@ export default class DelegatedStorageService { 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, }; @@ -195,7 +206,7 @@ export default class DelegatedStorageService { 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); @@ -235,7 +246,7 @@ export default class DelegatedStorageService { 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); @@ -263,7 +274,7 @@ export default class DelegatedStorageService { 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); } @@ -289,7 +300,7 @@ export default class DelegatedStorageService { const req: OnUnlockRequest = { typeInstanceId: input.typeInstance.id, - context: this.encode(input.backend.context), + context: DelegatedStorageService.encode(input.backend.context), }; await cli.onUnlock(req); } @@ -297,7 +308,7 @@ export default class DelegatedStorageService { private async storageInstanceDetailsFetcher( id: string - ): Promise { + ): Promise { const sess = this.dbDriver.session(); try { const fetchRevisionResult = await sess.run( @@ -326,7 +337,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( @@ -339,8 +355,8 @@ export default class DelegatedStorageService { private async getClient(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" ); @@ -350,12 +366,31 @@ export default class DelegatedStorageService { logger.debug("Initialize gRPC client", { backend: id, - url, + url: spec.url, }); - const channel = createChannel(url); - const client: StorageClient = createClient( + + const clientFactory = createClientFactory().use( + newValidateMiddleware(this.ajv) + ); + + let contextSchema = null; + if (spec.contextSchema) { + contextSchema = JSON.parse(spec.contextSchema) as JSONSchemaType<{ + [key: string]: any; + }>; + } + const channel = createChannel(spec.url); + const client: StorageClient = clientFactory.create( StorageBackendDefinition, - channel + channel, + { + "*": { + storageSpec: { + contextSchema, + acceptValue: spec.acceptValue, + }, + }, + } ); this.registeredClients.set(id, client); } @@ -375,4 +410,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" }) + ); + } +} + +interface ContextJSONSchema { + [key: string]: any; +} + +interface DenyOptions { + storageSpec?: { + contextSchema: JSONSchemaType | null; + acceptValue: boolean; + }; +} + +function newValidateMiddleware(ajv: Ajv): ClientMiddleware { + return async function* denyMiddleware(call, options) { + if (!options.storageSpec) { + return yield* call.next(call.request, options); + } + + const { storageSpec, ...restOptions } = options; + const hasValue = Object.prototype.hasOwnProperty.call( + call.request, + "value" + ); + const hasContext = Object.prototype.hasOwnProperty.call( + call.request, + "context" + ); + + if (!options.storageSpec?.acceptValue && hasValue) { + throw new ClientError( + call.method.path, + Status.INVALID_ARGUMENT, + "Delegated storage doesn't accept value" + ); + } + + if (hasContext) { + if (options.storageSpec.contextSchema === null) { + throw new ClientError( + call.method.path, + Status.INVALID_ARGUMENT, + "Delegated storage doesn't accept context" + ); + } + + console.log(options.storageSpec.contextSchema); + const validate = ajv.compile(options.storageSpec.contextSchema); + + // @ts-ignore + const ctx = new TextDecoder().decode(call.request.context); + const ctxObj: ContextJSONSchema = JSON.parse(ctx); + console.log(ctxObj); + console.log(typeof ctxObj); + console.log(typeof options.storageSpec.contextSchema); + if (validate(ctxObj)) { + } else { + throw new ClientError( + call.method.path, + Status.INVALID_ARGUMENT, + ajv.errorsText(validate.errors, { dataVar: "context" }) + ); + } + } + return yield* call.next(call.request, { + ...restOptions, + }); + }; } diff --git a/pkg/hub/client/local/client_test.go b/pkg/hub/client/local/client_test.go new file mode 100644 index 000000000..563eb927d --- /dev/null +++ b/pkg/hub/client/local/client_test.go @@ -0,0 +1,392 @@ +package local + +import ( + "capact.io/capact/internal/cli/heredoc" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + cliprinter "capact.io/capact/internal/cli/printer" + "capact.io/capact/internal/ptr" + gqllocalapi "capact.io/capact/pkg/hub/api/graphql/local" + pb "capact.io/capact/pkg/hub/api/grpc/storage_backend" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc" +) + +// TODO(review): THIS FILE WILL BE REMOVED BEFORE MERGING. IT WAS ADDED ONLY FOR DEMO/PR TESTING PURPOSES. + +// #nosec G101 +const secretStorageBackendAddr = "GRPC_SECRET_STORAGE_BACKEND_ADDR" + +type StorageValue struct { + URL *string `json:"url,omitempty"` + AcceptValue *bool `json:"acceptValue,omitempty"` + ContextSchema *string `json:"contextSchema,omitempty"` +} + +// This test showcase how to use GraphQL client to: +// - register TypeInstance for external Storage Backend +// - create a new TypeInstance stored in built-in backend +// - create a new TypeInstance stored in registered external backend +// - create a new TypeInstance stored in registered external backend with custom context +// - update TypeInstances +// - lock/unlock TypeInstance +// - delete all created TypeInstance +// +// Prerequisite: +// Before running this test, make sure that the external backend is running: +// APP_LOGGER_DEV_MODE=true APP_SUPPORTED_PROVIDERS="dotenv" go run ./cmd/secret-storage-backend/main.go +// and Local Hub: +// cd hub-js; APP_NEO4J_ENDPOINT=bolt://localhost:7687 APP_NEO4J_PASSWORD=okon APP_HUB_MODE=local npm run dev; cd .. +// +// To run this test, execute: +// GRPC_SECRET_STORAGE_BACKEND_ADDR="http://0.0.0.0:50051" go test ./pkg/hub/client/local/ -v -count 1 +func TestThatShowcaseExternalStorage(t *testing.T) { + srvAddr := os.Getenv(secretStorageBackendAddr) + if srvAddr == "" { + t.Skipf("skipping running example test as the env %s is not provided", secretStorageBackendAddr) + } + + ctx := context.Background() + cli := NewDefaultClient("http://localhost:8080/graphql") + dotenvHubStorage, cleanup := registerExternalDotenvStorage(ctx, t, cli, srvAddr) + defer cleanup() + + // SCENARIO - CREATE + family, err := cli.CreateTypeInstances(ctx, &gqllocalapi.CreateTypeInstancesInput{ + TypeInstances: []*gqllocalapi.CreateTypeInstanceInput{ + { + // This TypeInstance: + // - is stored in built-in backend + // - doesn't have backend context + Alias: ptr.String("child"), + CreatedBy: ptr.String("nature"), + TypeRef: typeRef("cap.type.child:0.1.0"), + Value: map[string]interface{}{ + "name": "Luke Skywalker", + }, + }, + { + // This TypeInstance: + // - is stored in external backend + // - has additional context + // - should be stored with mutated context (from create req) + Alias: ptr.String("second-child"), + CreatedBy: ptr.String("nature"), + TypeRef: typeRef("cap.type.child:0.2.0"), + Value: map[string]interface{}{ + "name": "Leia Organa", + }, + Backend: &gqllocalapi.TypeInstanceBackendInput{ + ID: dotenvHubStorage.ID, + Context: map[string]interface{}{ + "provider": "mock-me", // this will inform external backend to return mutated context + }, + }, + }, + { + // This TypeInstance: + // - is stored in external backend + // - doesn't have additional context + // - should be stored without mutated context + Alias: ptr.String("original"), + CreatedBy: ptr.String("nature"), + TypeRef: typeRef("cap.type.original:0.2.0"), + Value: map[string]interface{}{ + "name": "Anakin Skywalke", + }, + Backend: &gqllocalapi.TypeInstanceBackendInput{ + ID: dotenvHubStorage.ID, + // no context + }, + }, + { + // This TypeInstance: + // - is stored in external backend + // - has additional context + // - should be stored without mutated context + Alias: ptr.String("parent"), + CreatedBy: ptr.String("nature"), + TypeRef: typeRef("cap.type.parent:0.1.0"), + Value: map[string]interface{}{ + "name": "Darth Vader", + }, + Backend: &gqllocalapi.TypeInstanceBackendInput{ + ID: dotenvHubStorage.ID, + Context: map[string]interface{}{ + "provider": "dotenv", + }, + }, + }, + }, + UsesRelations: []*gqllocalapi.TypeInstanceUsesRelationInput{ + {From: "parent", To: "child"}, + {From: "parent", To: "second-child"}, + {From: "parent", To: "original"}, + }, + }) + require.NoError(t, err) + + familyDetails, err := cli.ListTypeInstances(ctx, &gqllocalapi.TypeInstanceFilter{ + CreatedBy: ptr.String("nature"), + }, WithFields(TypeInstanceAllFields)) + require.NoError(t, err) + + defer removeAllMembers(t, cli, familyDetails) + + fmt.Print("\n\n======== After create result ============\n\n") + resourcePrinter := cliprinter.NewForResource(os.Stdout, cliprinter.WithTable(typeInstanceDetailsMapper(family, getDataDirectlyFromStorage(t, srvAddr, familyDetails)))) + require.NoError(t, resourcePrinter.Print(familyDetails)) + + // SCENARIO - UPDATE + // - for cap.type.parent don't update `value` and `context` - use old ones + // - for cap.type.original:0.2.0 don't update `value`, and zero the `context` + // - for all others, update both `value` and `context` + toUpdate := make([]gqllocalapi.UpdateTypeInstancesInput, 0, len(familyDetails)) + for idx, member := range familyDetails { + val := map[string]interface{}{ + "updated-value": fmt.Sprintf("context %d", idx), + } + backend := &gqllocalapi.UpdateTypeInstanceBackendInput{ + Context: map[string]interface{}{ + "updated": fmt.Sprintf("context %d", idx), + }, + } + // For cap.type.original:0.2.0 don't update value, and zero the `context`. + if member.TypeRef.Path == "cap.type.original" { + val = nil + backend.Context = nil + } + + // For cap.type.parent don't update value and `context` - use old ones + if member.TypeRef.Path == "cap.type.parent" { + val = nil + backend = nil + } + toUpdate = append(toUpdate, gqllocalapi.UpdateTypeInstancesInput{ + ID: member.ID, + CreatedBy: ptr.String("update"), + TypeInstance: &gqllocalapi.UpdateTypeInstanceInput{ + Value: val, + Backend: backend, + }, + }) + } + + updatedFamily, err := cli.UpdateTypeInstances(ctx, toUpdate) + require.NoError(t, err) + + fmt.Print("\n\n======== After update result ============\n\n") + resourcePrinter = cliprinter.NewForResource(os.Stdout, cliprinter.WithTable(typeInstanceDetailsMapper(family, getDataDirectlyFromStorage(t, srvAddr, updatedFamily)))) + require.NoError(t, resourcePrinter.Print(updatedFamily)) + + // SCENARIO - LOCK + var ids []string + for _, member := range familyDetails { + ids = append(ids, member.ID) + } + err = cli.LockTypeInstances(ctx, &gqllocalapi.LockTypeInstancesInput{ + Ids: ids, + OwnerID: "demo/testing", + }) + require.NoError(t, err) + familyDetails, err = cli.ListTypeInstances(ctx, &gqllocalapi.TypeInstanceFilter{ + CreatedBy: ptr.String("nature"), + }, WithFields(TypeInstanceAllFields)) + require.NoError(t, err) + + fmt.Print("\n\n======== After locking result ============\n\n") + resourcePrinter = cliprinter.NewForResource(os.Stdout, cliprinter.WithTable(typeInstanceDetailsMapper(family, getDataDirectlyFromStorage(t, srvAddr, familyDetails)))) + require.NoError(t, resourcePrinter.Print(familyDetails)) + + // SCENARIO - UNLOCK + err = cli.UnlockTypeInstances(ctx, &gqllocalapi.UnlockTypeInstancesInput{ + Ids: ids, + OwnerID: "demo/testing", + }) + require.NoError(t, err) + familyDetails, err = cli.ListTypeInstances(ctx, &gqllocalapi.TypeInstanceFilter{ + CreatedBy: ptr.String("nature"), + }, WithFields(TypeInstanceAllFields)) + require.NoError(t, err) + + fmt.Print("\n\n======== After unlocking result ============\n\n") + resourcePrinter = cliprinter.NewForResource(os.Stdout, cliprinter.WithTable(typeInstanceDetailsMapper(family, getDataDirectlyFromStorage(t, srvAddr, familyDetails)))) + require.NoError(t, resourcePrinter.Print(familyDetails)) +} + +// ======= HELPERS ======= + +func registerExternalDotenvStorage(ctx context.Context, t *testing.T, cli *Client, srvAddr string) (gqllocalapi.CreateTypeInstanceOutput, func()) { + t.Helper() + + ti, err := cli.CreateTypeInstances(ctx, fixExternalDotenvStorage(srvAddr)) + require.NoError(t, err) + require.Len(t, ti, 1) + dotenvHubStorage := ti[0] + + return dotenvHubStorage, func() { + _ = cli.DeleteTypeInstance(ctx, dotenvHubStorage.ID) + } +} + +func fixExternalDotenvStorage(addr string) *gqllocalapi.CreateTypeInstancesInput { + return &gqllocalapi.CreateTypeInstancesInput{ + TypeInstances: []*gqllocalapi.CreateTypeInstanceInput{ + { + CreatedBy: ptr.String("manually"), + TypeRef: &gqllocalapi.TypeInstanceTypeReferenceInput{ + Path: "cap.type.example.filesystem.storage", + Revision: "0.1.0", + }, + Value: StorageValue{ + URL: ptr.String(addr), + AcceptValue: ptr.Bool(true), + ContextSchema: ptr.String(heredoc.Doc(` + { + "$id": "#/properties/contextSchema", + "type": "object", + "properties": { + "provider": { + "$id": "#/properties/contextSchema/properties/name", + "type": "string" + } + }, + "additionalProperties": false + }`)), + }, + }, + }, + UsesRelations: []*gqllocalapi.TypeInstanceUsesRelationInput{}, + } +} + +type externalData struct { + Value string + LockedBy *string +} + +func getDataDirectlyFromStorage(t *testing.T, addr string, details []gqllocalapi.TypeInstance) map[string]externalData { + t.Helper() + + conn, err := grpc.Dial(addr, grpc.WithInsecure()) + require.NoError(t, err) + + ctx := context.Background() + client := pb.NewStorageBackendClient(conn) + + var out = map[string]externalData{} + for _, ti := range details { + val, err := client.GetValue(ctx, &pb.GetValueRequest{ + TypeInstanceId: ti.ID, + ResourceVersion: uint32(ti.LatestResourceVersion.ResourceVersion), + }) + if err != nil { + continue + } + + locked, err := client.GetLockedBy(ctx, &pb.GetLockedByRequest{ + TypeInstanceId: ti.ID, + }) + if err != nil { + continue + } + + out[ti.ID] = externalData{ + Value: string(val.Value), + LockedBy: locked.LockedBy, + } + } + return out +} + +func typeInstanceDetailsMapper(family []gqllocalapi.CreateTypeInstanceOutput, storage map[string]externalData) func(inRaw interface{}) (cliprinter.TableData, error) { + mapping := map[string]string{} + for _, member := range family { + mapping[member.ID] = member.Alias + } + labelIfAbstract := func(in bool) string { + if in { + return " (abstract)" + } + return "" + } + return func(inRaw interface{}) (cliprinter.TableData, error) { + out := cliprinter.TableData{} + + switch in := inRaw.(type) { + case []gqllocalapi.TypeInstance: + out.Headers = []string{"TYPE INSTANCE ID", "ALIAS", "TYPE", "BACKEND", "BACKEND CONTEXT", "DATA IN GQL", "DATA IN exBACKEND", "LOCKED", "LOCKED IN exBACKEND"} + for _, ti := range in { + out.MultipleRows = append(out.MultipleRows, []string{ + ti.ID, + mapping[ti.ID], + fmt.Sprintf("%s:%s", ti.TypeRef.Path, ti.TypeRef.Revision), + fmt.Sprintf("%s%s", ti.Backend.ID, labelIfAbstract(ti.Backend.Abstract)), + mustMarshal(ti.LatestResourceVersion.Spec.Backend.Context), + mustMarshal(ti.LatestResourceVersion.Spec.Value), + storage[ti.ID].Value, + stringDefault(ti.LockedBy, "-"), + stringDefault(storage[ti.ID].LockedBy, "-"), + }) + } + default: + return cliprinter.TableData{}, fmt.Errorf("got unexpected input type, expected []gqllocalapi.TypeInstance, got %T", inRaw) + } + + return out, nil + } +} + +func mustMarshal(v interface{}) string { + out, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(out) +} + +func stringDefault(in *string, def string) string { + if in == nil { + return def + } + return *in +} + +func typeRef(in string) *gqllocalapi.TypeInstanceTypeReferenceInput { + out := strings.Split(in, ":") + return &gqllocalapi.TypeInstanceTypeReferenceInput{Path: out[0], Revision: out[1]} +} + +func removeAllMembers(t *testing.T, cli *Client, familyDetails []gqllocalapi.TypeInstance) { + t.Helper() + + ctx := context.Background() + + for _, member := range familyDetails { + if member.TypeRef.Path != "cap.type.parent" { + defer func(id string) { // delay the child deletions + fmt.Println("Delete child", id) + err := cli.DeleteTypeInstance(ctx, id) + if err != nil { + t.Logf("err for %v: %v", id, err) + } + }(member.ID) + + continue + } + + fmt.Println("Delete parent", member.ID) + + // Delete parent first, to unblock deletion of children + err := cli.DeleteTypeInstance(ctx, member.ID) + if err != nil { + t.Logf("err for %v: %v", member.ID, err) + } + } +} From 8348f8a407aedb57f4f011b6bf974cfdf439a0e7 Mon Sep 17 00:00:00 2001 From: Mateusz Szostok Date: Thu, 10 Mar 2022 14:18:14 +0100 Subject: [PATCH 2/3] Move validation directly to method call --- hub-js/src/local/storage/service.ts | 263 ++++++++------ pkg/hub/client/local/client_test.go | 526 +++++++++++----------------- 2 files changed, 360 insertions(+), 429 deletions(-) diff --git a/hub-js/src/local/storage/service.ts b/hub-js/src/local/storage/service.ts index 46d51f2ae..2d7a28bc6 100644 --- a/hub-js/src/local/storage/service.ts +++ b/hub-js/src/local/storage/service.ts @@ -7,14 +7,7 @@ import { OnUpdateRequest, StorageBackendDefinition, } from "../../generated/grpc/storage_backend"; -import { - Client, - ClientError, - ClientMiddleware, - createChannel, - createClientFactory, - Status, -} 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"; @@ -25,7 +18,7 @@ import { StorageTypeInstanceSpecSchema, } from "./backend-schema"; import { JSONSchemaType } from "ajv/lib/types/json-schema"; -import { TextDecoder, TextEncoder } from "util"; +import { TextEncoder } from "util"; // TODO(https://github.com/capactio/capact/issues/634): // Represents the fake storage backend URL that should be ignored @@ -33,7 +26,20 @@ import { TextDecoder, TextEncoder } from "util"; // It should be removed after a real backend is used in `test/e2e/action_test.go` scenarios. export const FAKE_TEST_URL = "e2e-test-backend-mock-url:50051"; -type StorageClient = Client; +type StorageClient = Client; + +interface BackendContainer { + client: StorageClient; + validateSpec: ValidateBackendSpec; +} + +interface ValidateBackendSpec { + backendId: string; + contextSchema: JSONSchemaType | undefined; + acceptValue: boolean; +} + +type ValidateInput = GetInput | UpdateInput | DeleteInput | StoreInput; export interface StoreInput { backend: TypeInstanceBackendInput; @@ -89,12 +95,12 @@ export interface UpdatedContexts { } export default class DelegatedStorageService { - private registeredClients: Map; + 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 }); @@ -117,18 +123,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: 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; @@ -157,12 +171,19 @@ 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, @@ -171,7 +192,7 @@ export default class DelegatedStorageService { ownerId: input.typeInstance.ownerID, }; - await cli.onUpdate(req); + await backend.client.onUpdate(req); } } @@ -191,8 +212,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, @@ -203,12 +224,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: DelegatedStorageService.encode(input.backend.context), }; - const res = await cli.getValue(req); + const res = await backend.client.getValue(req); if (!res.value) { throw Error( @@ -238,18 +266,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: DelegatedStorageService.encode(input.backend.context), ownerId: input.typeInstance.ownerID, }; - await cli.onDelete(req); + await backend.client.onDelete(req); } } @@ -265,18 +299,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: DelegatedStorageService.encode(input.backend.context), }; - await cli.onLock(req); + await backend.client.onLock(req); } } @@ -292,17 +333,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: OnUnlockRequest = { typeInstanceId: input.typeInstance.id, context: DelegatedStorageService.encode(input.backend.context), }; - await cli.onUnlock(req); + await backend.client.onUnlock(req); } } @@ -353,7 +401,9 @@ export default class DelegatedStorageService { } } - private async getClient(id: string): Promise { + private async getBackendContainer( + id: string + ): Promise { if (!this.registeredClients.has(id)) { const spec = await this.storageInstanceDetailsFetcher(id); if (spec.url === FAKE_TEST_URL) { @@ -364,35 +414,34 @@ export default class DelegatedStorageService { return undefined; } - logger.debug("Initialize gRPC client", { + logger.debug("Initialize gRPC BackendContainer", { backend: id, url: spec.url, }); - const clientFactory = createClientFactory().use( - newValidateMiddleware(this.ajv) - ); - - let contextSchema = null; + let contextSchema; if (spec.contextSchema) { - contextSchema = JSON.parse(spec.contextSchema) as JSONSchemaType<{ - [key: string]: any; - }>; + 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 = clientFactory.create( + const client: StorageClient = createClient( StorageBackendDefinition, - channel, - { - "*": { - storageSpec: { - contextSchema, - acceptValue: spec.acceptValue, - }, - }, - } + 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); @@ -410,6 +459,7 @@ export default class DelegatedStorageService { DelegatedStorageService.convertToJSONIfObject(val) ); } + private validateStorageSpecValue(storageSpec: StorageTypeInstanceSpec) { const validate = this.ajv.compile(StorageTypeInstanceSpecSchema); @@ -421,72 +471,71 @@ export default class DelegatedStorageService { this.ajv.errorsText(validate.errors, { dataVar: "spec.value" }) ); } -} -interface ContextJSONSchema { - [key: string]: any; -} + private static encode(val: unknown) { + return new TextEncoder().encode( + DelegatedStorageService.convertToJSONIfObject(val) + ); + } -interface DenyOptions { - storageSpec?: { - contextSchema: JSONSchemaType | null; - acceptValue: boolean; - }; -} + private static normalizeInput( + input: GetInput | UpdateInput | DeleteInput | StoreInput + ) { + const out: { context?: unknown; value?: unknown } = { + context: input.backend.context, + value: undefined, + }; -function newValidateMiddleware(ajv: Ajv): ClientMiddleware { - return async function* denyMiddleware(call, options) { - if (!options.storageSpec) { - return yield* call.next(call.request, options); + if ("value" in input.typeInstance) { + out.value = input.typeInstance.value; } + if ("newValue" in input.typeInstance) { + out.value = input.typeInstance.newValue; + } + return out; + } - const { storageSpec, ...restOptions } = options; - const hasValue = Object.prototype.hasOwnProperty.call( - call.request, - "value" - ); - const hasContext = Object.prototype.hasOwnProperty.call( - call.request, - "context" - ); + private validateInput( + input: ValidateInput, + storageSpec: ValidateBackendSpec + ): Error | undefined { + const { value, context } = DelegatedStorageService.normalizeInput(input); - if (!options.storageSpec?.acceptValue && hasValue) { - throw new ClientError( - call.method.path, - Status.INVALID_ARGUMENT, - "Delegated storage doesn't accept value" - ); + if (!storageSpec.acceptValue && value) { + return Error("input value not allowed"); } - if (hasContext) { - if (options.storageSpec.contextSchema === null) { - throw new ClientError( - call.method.path, - Status.INVALID_ARGUMENT, - "Delegated storage doesn't accept context" - ); + if (context) { + if (storageSpec.contextSchema === undefined) { + return Error("input context not allowed"); } - console.log(options.storageSpec.contextSchema); - const validate = ajv.compile(options.storageSpec.contextSchema); - - // @ts-ignore - const ctx = new TextDecoder().decode(call.request.context); - const ctxObj: ContextJSONSchema = JSON.parse(ctx); - console.log(ctxObj); - console.log(typeof ctxObj); - console.log(typeof options.storageSpec.contextSchema); - if (validate(ctxObj)) { - } else { - throw new ClientError( - call.method.path, - Status.INVALID_ARGUMENT, - ajv.errorsText(validate.errors, { dataVar: "context" }) - ); + const validate = this.ajv.compile(storageSpec.contextSchema); + if (!validate(context)) { + const msg = this.ajv.errorsText(validate.errors, { + dataVar: "context", + }); + return Error(`invalid input: ${msg}`); } } - return yield* call.next(call.request, { - ...restOptions, - }); - }; + + 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/pkg/hub/client/local/client_test.go b/pkg/hub/client/local/client_test.go index 563eb927d..66ce86284 100644 --- a/pkg/hub/client/local/client_test.go +++ b/pkg/hub/client/local/client_test.go @@ -1,253 +1,114 @@ package local import ( - "capact.io/capact/internal/cli/heredoc" "context" - "encoding/json" - "fmt" "os" + "regexp" "strings" "testing" - cliprinter "capact.io/capact/internal/cli/printer" + "capact.io/capact/internal/cli/heredoc" "capact.io/capact/internal/ptr" gqllocalapi "capact.io/capact/pkg/hub/api/graphql/local" - pb "capact.io/capact/pkg/hub/api/grpc/storage_backend" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "google.golang.org/grpc" ) // TODO(review): THIS FILE WILL BE REMOVED BEFORE MERGING. IT WAS ADDED ONLY FOR DEMO/PR TESTING PURPOSES. -// #nosec G101 -const secretStorageBackendAddr = "GRPC_SECRET_STORAGE_BACKEND_ADDR" +const ( + RunValidation = "RUN_VALIDATION" + typeInstanceTypeRef = "cap.type.testing:0.1.0" +) -type StorageValue struct { +type StorageSpec struct { URL *string `json:"url,omitempty"` AcceptValue *bool `json:"acceptValue,omitempty"` ContextSchema *string `json:"contextSchema,omitempty"` } -// This test showcase how to use GraphQL client to: -// - register TypeInstance for external Storage Backend -// - create a new TypeInstance stored in built-in backend -// - create a new TypeInstance stored in registered external backend -// - create a new TypeInstance stored in registered external backend with custom context -// - update TypeInstances -// - lock/unlock TypeInstance -// - delete all created TypeInstance -// // Prerequisite: -// Before running this test, make sure that the external backend is running: -// APP_LOGGER_DEV_MODE=true APP_SUPPORTED_PROVIDERS="dotenv" go run ./cmd/secret-storage-backend/main.go -// and Local Hub: -// cd hub-js; APP_NEO4J_ENDPOINT=bolt://localhost:7687 APP_NEO4J_PASSWORD=okon APP_HUB_MODE=local npm run dev; cd .. +// Before running this test, make sure that Local Hub is running: +// cd hub-js; APP_NEO4J_ENDPOINT=bolt://localhost:7687 APP_NEO4J_PASSWORD=okon APP_HUB_MODE=local npm run dev // // To run this test, execute: -// GRPC_SECRET_STORAGE_BACKEND_ADDR="http://0.0.0.0:50051" go test ./pkg/hub/client/local/ -v -count 1 -func TestThatShowcaseExternalStorage(t *testing.T) { - srvAddr := os.Getenv(secretStorageBackendAddr) - if srvAddr == "" { - t.Skipf("skipping running example test as the env %s is not provided", secretStorageBackendAddr) +// RUN_VALIDATION=true go test ./pkg/hub/client/local/ -v -count 1 +func TestExternalStorageInputValidation(t *testing.T) { + run := os.Getenv(RunValidation) + if run == "" { + t.Skipf("skipping running example test as the env %s is not provided", RunValidation) } ctx := context.Background() cli := NewDefaultClient("http://localhost:8080/graphql") - dotenvHubStorage, cleanup := registerExternalDotenvStorage(ctx, t, cli, srvAddr) - defer cleanup() - // SCENARIO - CREATE - family, err := cli.CreateTypeInstances(ctx, &gqllocalapi.CreateTypeInstancesInput{ - TypeInstances: []*gqllocalapi.CreateTypeInstanceInput{ - { - // This TypeInstance: - // - is stored in built-in backend - // - doesn't have backend context - Alias: ptr.String("child"), - CreatedBy: ptr.String("nature"), - TypeRef: typeRef("cap.type.child:0.1.0"), - Value: map[string]interface{}{ - "name": "Luke Skywalker", - }, + tests := map[string]struct { + // given + storageSpec interface{} + value map[string]interface{} + context interface{} + + // then + expErrMsg string + }{ + "Should rejected value": { + storageSpec: StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), }, - { - // This TypeInstance: - // - is stored in external backend - // - has additional context - // - should be stored with mutated context (from create req) - Alias: ptr.String("second-child"), - CreatedBy: ptr.String("nature"), - TypeRef: typeRef("cap.type.child:0.2.0"), - Value: map[string]interface{}{ - "name": "Leia Organa", - }, - Backend: &gqllocalapi.TypeInstanceBackendInput{ - ID: dotenvHubStorage.ID, - Context: map[string]interface{}{ - "provider": "mock-me", // this will inform external backend to return mutated context - }, - }, + value: map[string]interface{}{ + "name": "Luke Skywalker", }, - { - // This TypeInstance: - // - is stored in external backend - // - doesn't have additional context - // - should be stored without mutated context - Alias: ptr.String("original"), - CreatedBy: ptr.String("nature"), - TypeRef: typeRef("cap.type.original:0.2.0"), - Value: map[string]interface{}{ - "name": "Anakin Skywalke", - }, - Backend: &gqllocalapi.TypeInstanceBackendInput{ - ID: dotenvHubStorage.ID, - // no context - }, + expErrMsg: 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`), + }, + "Should rejected context": { + storageSpec: StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), }, - { - // This TypeInstance: - // - is stored in external backend - // - has additional context - // - should be stored without mutated context - Alias: ptr.String("parent"), - CreatedBy: ptr.String("nature"), - TypeRef: typeRef("cap.type.parent:0.1.0"), - Value: map[string]interface{}{ - "name": "Darth Vader", - }, - Backend: &gqllocalapi.TypeInstanceBackendInput{ - ID: dotenvHubStorage.ID, - Context: map[string]interface{}{ - "provider": "dotenv", - }, - }, + context: map[string]interface{}{ + "name": "Luke Skywalker", }, + expErrMsg: 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`), }, - UsesRelations: []*gqllocalapi.TypeInstanceUsesRelationInput{ - {From: "parent", To: "child"}, - {From: "parent", To: "second-child"}, - {From: "parent", To: "original"}, - }, - }) - require.NoError(t, err) - - familyDetails, err := cli.ListTypeInstances(ctx, &gqllocalapi.TypeInstanceFilter{ - CreatedBy: ptr.String("nature"), - }, WithFields(TypeInstanceAllFields)) - require.NoError(t, err) - - defer removeAllMembers(t, cli, familyDetails) - - fmt.Print("\n\n======== After create result ============\n\n") - resourcePrinter := cliprinter.NewForResource(os.Stdout, cliprinter.WithTable(typeInstanceDetailsMapper(family, getDataDirectlyFromStorage(t, srvAddr, familyDetails)))) - require.NoError(t, resourcePrinter.Print(familyDetails)) - - // SCENARIO - UPDATE - // - for cap.type.parent don't update `value` and `context` - use old ones - // - for cap.type.original:0.2.0 don't update `value`, and zero the `context` - // - for all others, update both `value` and `context` - toUpdate := make([]gqllocalapi.UpdateTypeInstancesInput, 0, len(familyDetails)) - for idx, member := range familyDetails { - val := map[string]interface{}{ - "updated-value": fmt.Sprintf("context %d", idx), - } - backend := &gqllocalapi.UpdateTypeInstanceBackendInput{ - Context: map[string]interface{}{ - "updated": fmt.Sprintf("context %d", idx), - }, - } - // For cap.type.original:0.2.0 don't update value, and zero the `context`. - if member.TypeRef.Path == "cap.type.original" { - val = nil - backend.Context = nil - } - - // For cap.type.parent don't update value and `context` - use old ones - if member.TypeRef.Path == "cap.type.parent" { - val = nil - backend = nil - } - toUpdate = append(toUpdate, gqllocalapi.UpdateTypeInstancesInput{ - ID: member.ID, - CreatedBy: ptr.String("update"), - TypeInstance: &gqllocalapi.UpdateTypeInstanceInput{ - Value: val, - Backend: backend, + "Should return error that context is not an object": { + storageSpec: 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 + }`)), }, - }) - } - - updatedFamily, err := cli.UpdateTypeInstances(ctx, toUpdate) - require.NoError(t, err) - - fmt.Print("\n\n======== After update result ============\n\n") - resourcePrinter = cliprinter.NewForResource(os.Stdout, cliprinter.WithTable(typeInstanceDetailsMapper(family, getDataDirectlyFromStorage(t, srvAddr, updatedFamily)))) - require.NoError(t, resourcePrinter.Print(updatedFamily)) - - // SCENARIO - LOCK - var ids []string - for _, member := range familyDetails { - ids = append(ids, member.ID) - } - err = cli.LockTypeInstances(ctx, &gqllocalapi.LockTypeInstancesInput{ - Ids: ids, - OwnerID: "demo/testing", - }) - require.NoError(t, err) - familyDetails, err = cli.ListTypeInstances(ctx, &gqllocalapi.TypeInstanceFilter{ - CreatedBy: ptr.String("nature"), - }, WithFields(TypeInstanceAllFields)) - require.NoError(t, err) - - fmt.Print("\n\n======== After locking result ============\n\n") - resourcePrinter = cliprinter.NewForResource(os.Stdout, cliprinter.WithTable(typeInstanceDetailsMapper(family, getDataDirectlyFromStorage(t, srvAddr, familyDetails)))) - require.NoError(t, resourcePrinter.Print(familyDetails)) - - // SCENARIO - UNLOCK - err = cli.UnlockTypeInstances(ctx, &gqllocalapi.UnlockTypeInstancesInput{ - Ids: ids, - OwnerID: "demo/testing", - }) - require.NoError(t, err) - familyDetails, err = cli.ListTypeInstances(ctx, &gqllocalapi.TypeInstanceFilter{ - CreatedBy: ptr.String("nature"), - }, WithFields(TypeInstanceAllFields)) - require.NoError(t, err) - - fmt.Print("\n\n======== After unlocking result ============\n\n") - resourcePrinter = cliprinter.NewForResource(os.Stdout, cliprinter.WithTable(typeInstanceDetailsMapper(family, getDataDirectlyFromStorage(t, srvAddr, familyDetails)))) - require.NoError(t, resourcePrinter.Print(familyDetails)) -} - -// ======= HELPERS ======= - -func registerExternalDotenvStorage(ctx context.Context, t *testing.T, cli *Client, srvAddr string) (gqllocalapi.CreateTypeInstanceOutput, func()) { - t.Helper() - - ti, err := cli.CreateTypeInstances(ctx, fixExternalDotenvStorage(srvAddr)) - require.NoError(t, err) - require.Len(t, ti, 1) - dotenvHubStorage := ti[0] - - return dotenvHubStorage, func() { - _ = cli.DeleteTypeInstance(ctx, dotenvHubStorage.ID) - } -} - -func fixExternalDotenvStorage(addr string) *gqllocalapi.CreateTypeInstancesInput { - return &gqllocalapi.CreateTypeInstancesInput{ - TypeInstances: []*gqllocalapi.CreateTypeInstanceInput{ - { - CreatedBy: ptr.String("manually"), - TypeRef: &gqllocalapi.TypeInstanceTypeReferenceInput{ - Path: "cap.type.example.filesystem.storage", - Revision: "0.1.0", - }, - Value: StorageValue{ - URL: ptr.String(addr), - AcceptValue: ptr.Bool(true), - ContextSchema: ptr.String(heredoc.Doc(` + context: "Luke Skywalker", + expErrMsg: 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`), + }, + "Should return validation error for context": { + storageSpec: StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), + ContextSchema: ptr.String(heredoc.Doc(` { "$id": "#/properties/contextSchema", "type": "object", @@ -259,134 +120,155 @@ func fixExternalDotenvStorage(addr string) *gqllocalapi.CreateTypeInstancesInput }, "additionalProperties": false }`)), - }, }, + context: map[string]interface{}{ + "provider": true, + }, + expErrMsg: 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`), + }, + "Should reject value and context": { + storageSpec: StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), + }, + value: map[string]interface{}{ + "name": "Luke Skywalker", + }, + context: map[string]interface{}{ + "name": "Luke Skywalker", + }, + // TODO(review): currently it's a an early return, is it sufficient? + // if not, we will need to an support for throwing multierr to print aggregated data in higher layer. + expErrMsg: 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`), }, - UsesRelations: []*gqllocalapi.TypeInstanceUsesRelationInput{}, - } -} - -type externalData struct { - Value string - LockedBy *string -} - -func getDataDirectlyFromStorage(t *testing.T, addr string, details []gqllocalapi.TypeInstance) map[string]externalData { - t.Helper() - conn, err := grpc.Dial(addr, grpc.WithInsecure()) - require.NoError(t, err) + // Invalid Storage TypeInstance + "Should reject usage of backend without URL field": { + storageSpec: StorageSpec{ + AcceptValue: ptr.Bool(false), + }, + value: map[string]interface{}{ + "name": "Luke Skywalker", + }, + expErrMsg: 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'`), + }, + "Should reject usage of backend without AcceptValue field": { + storageSpec: StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + }, + value: map[string]interface{}{ + "name": "Luke Skywalker", + }, + expErrMsg: 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'`), + }, + "Should reject usage of backend without URL and AcceptValue fields": { + storageSpec: map[string]interface{}{ + "other-data": true, + }, + value: map[string]interface{}{ + "name": "Luke Skywalker", + }, + expErrMsg: 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'`), + }, + "Should reject usage of backend with wrong context schema": { + storageSpec: StorageSpec{ + URL: ptr.String("http://localhost:5000/fake"), + AcceptValue: ptr.Bool(false), + ContextSchema: ptr.String(heredoc.Doc(` + yaml: true`)), + }, + value: map[string]interface{}{ + "name": "Luke Skywalker", + }, + expErrMsg: 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`), + }, + } + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + // given + externalStorageID, cleanup := registerExternalStorage(ctx, t, cli, tc.storageSpec) + defer cleanup() + + // when + _, err := cli.CreateTypeInstance(ctx, &gqllocalapi.CreateTypeInstanceInput{ + TypeRef: typeRef(typeInstanceTypeRef), + Value: tc.value, + Backend: &gqllocalapi.TypeInstanceBackendInput{ + ID: externalStorageID, + Context: tc.context, + }, + }) - ctx := context.Background() - client := pb.NewStorageBackendClient(conn) + require.Error(t, err) - var out = map[string]externalData{} - for _, ti := range details { - val, err := client.GetValue(ctx, &pb.GetValueRequest{ - TypeInstanceId: ti.ID, - ResourceVersion: uint32(ti.LatestResourceVersion.ResourceVersion), - }) - if err != nil { - continue - } + regex := regexp.MustCompile(`\w{8}-\w{4}-\w{4}-\w{4}-\w{12}`) + gotErr := regex.ReplaceAllString(err.Error(), "MOCKED_ID") - locked, err := client.GetLockedBy(ctx, &pb.GetLockedByRequest{ - TypeInstanceId: ti.ID, + // then + assert.Equal(t, tc.expErrMsg, gotErr) }) - if err != nil { - continue - } - - out[ti.ID] = externalData{ - Value: string(val.Value), - LockedBy: locked.LockedBy, - } } - return out + + // sanity check + familyDetails, err := cli.ListTypeInstances(ctx, &gqllocalapi.TypeInstanceFilter{ + TypeRef: &gqllocalapi.TypeRefFilterInput{ + Path: typeRef(typeInstanceTypeRef).Path, + Revision: ptr.String(typeRef(typeInstanceTypeRef).Revision), + }, + }, WithFields(TypeInstanceRootFields)) + require.NoError(t, err) + assert.Len(t, familyDetails, 0) } -func typeInstanceDetailsMapper(family []gqllocalapi.CreateTypeInstanceOutput, storage map[string]externalData) func(inRaw interface{}) (cliprinter.TableData, error) { - mapping := map[string]string{} - for _, member := range family { - mapping[member.ID] = member.Alias - } - labelIfAbstract := func(in bool) string { - if in { - return " (abstract)" - } - return "" - } - return func(inRaw interface{}) (cliprinter.TableData, error) { - out := cliprinter.TableData{} +// ======= HELPERS ======= - switch in := inRaw.(type) { - case []gqllocalapi.TypeInstance: - out.Headers = []string{"TYPE INSTANCE ID", "ALIAS", "TYPE", "BACKEND", "BACKEND CONTEXT", "DATA IN GQL", "DATA IN exBACKEND", "LOCKED", "LOCKED IN exBACKEND"} - for _, ti := range in { - out.MultipleRows = append(out.MultipleRows, []string{ - ti.ID, - mapping[ti.ID], - fmt.Sprintf("%s:%s", ti.TypeRef.Path, ti.TypeRef.Revision), - fmt.Sprintf("%s%s", ti.Backend.ID, labelIfAbstract(ti.Backend.Abstract)), - mustMarshal(ti.LatestResourceVersion.Spec.Backend.Context), - mustMarshal(ti.LatestResourceVersion.Spec.Value), - storage[ti.ID].Value, - stringDefault(ti.LockedBy, "-"), - stringDefault(storage[ti.ID].LockedBy, "-"), - }) - } - default: - return cliprinter.TableData{}, fmt.Errorf("got unexpected input type, expected []gqllocalapi.TypeInstance, got %T", inRaw) - } +func registerExternalStorage(ctx context.Context, t *testing.T, cli *Client, value interface{}) (string, func()) { + t.Helper() - return out, nil - } -} + externalStorageID, err := cli.CreateTypeInstance(ctx, fixExternalDotenvStorage(value)) + require.NoError(t, err) + require.NotEmpty(t, externalStorageID) -func mustMarshal(v interface{}) string { - out, err := json.Marshal(v) - if err != nil { - panic(err) + return externalStorageID, func() { + _ = cli.DeleteTypeInstance(ctx, externalStorageID) } - return string(out) } -func stringDefault(in *string, def string) string { - if in == nil { - return def +func fixExternalDotenvStorage(value interface{}) *gqllocalapi.CreateTypeInstanceInput { + return &gqllocalapi.CreateTypeInstanceInput{ + TypeRef: &gqllocalapi.TypeInstanceTypeReferenceInput{ + Path: "cap.type.example.filesystem.storage", + Revision: "0.1.0", + }, + Value: value, } - return *in } func typeRef(in string) *gqllocalapi.TypeInstanceTypeReferenceInput { out := strings.Split(in, ":") return &gqllocalapi.TypeInstanceTypeReferenceInput{Path: out[0], Revision: out[1]} } - -func removeAllMembers(t *testing.T, cli *Client, familyDetails []gqllocalapi.TypeInstance) { - t.Helper() - - ctx := context.Background() - - for _, member := range familyDetails { - if member.TypeRef.Path != "cap.type.parent" { - defer func(id string) { // delay the child deletions - fmt.Println("Delete child", id) - err := cli.DeleteTypeInstance(ctx, id) - if err != nil { - t.Logf("err for %v: %v", id, err) - } - }(member.ID) - - continue - } - - fmt.Println("Delete parent", member.ID) - - // Delete parent first, to unblock deletion of children - err := cli.DeleteTypeInstance(ctx, member.ID) - if err != nil { - t.Logf("err for %v: %v", member.ID, err) - } - } -} From 088ad4b6871b8fca27044d3c74a3e9ba00b6f450 Mon Sep 17 00:00:00 2001 From: Mateusz Szostok Date: Thu, 17 Mar 2022 09:38:23 +0100 Subject: [PATCH 3/3] Apply suggestions after review --- hub-js/src/local/storage/service.ts | 15 +- pkg/hub/client/local/client_test.go | 274 ---------------------------- test/e2e/hub_test.go | 205 +++++++++++++++++++++ 3 files changed, 216 insertions(+), 278 deletions(-) delete mode 100644 pkg/hub/client/local/client_test.go diff --git a/hub-js/src/local/storage/service.ts b/hub-js/src/local/storage/service.ts index 2d7a28bc6..42fa1fb5b 100644 --- a/hub-js/src/local/storage/service.ts +++ b/hub-js/src/local/storage/service.ts @@ -41,6 +41,13 @@ interface ValidateBackendSpec { type ValidateInput = GetInput | UpdateInput | DeleteInput | StoreInput; +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } +} + export interface StoreInput { backend: TypeInstanceBackendInput; typeInstance: { @@ -498,16 +505,16 @@ export default class DelegatedStorageService { private validateInput( input: ValidateInput, storageSpec: ValidateBackendSpec - ): Error | undefined { + ): ValidationError | undefined { const { value, context } = DelegatedStorageService.normalizeInput(input); if (!storageSpec.acceptValue && value) { - return Error("input value not allowed"); + return new ValidationError("input value not allowed"); } if (context) { if (storageSpec.contextSchema === undefined) { - return Error("input context not allowed"); + return new ValidationError("input context not allowed"); } const validate = this.ajv.compile(storageSpec.contextSchema); @@ -515,7 +522,7 @@ export default class DelegatedStorageService { const msg = this.ajv.errorsText(validate.errors, { dataVar: "context", }); - return Error(`invalid input: ${msg}`); + return new ValidationError(`invalid input: ${msg}`); } } diff --git a/pkg/hub/client/local/client_test.go b/pkg/hub/client/local/client_test.go deleted file mode 100644 index 66ce86284..000000000 --- a/pkg/hub/client/local/client_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package local - -import ( - "context" - "os" - "regexp" - "strings" - "testing" - - "capact.io/capact/internal/cli/heredoc" - "capact.io/capact/internal/ptr" - gqllocalapi "capact.io/capact/pkg/hub/api/graphql/local" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// TODO(review): THIS FILE WILL BE REMOVED BEFORE MERGING. IT WAS ADDED ONLY FOR DEMO/PR TESTING PURPOSES. - -const ( - RunValidation = "RUN_VALIDATION" - typeInstanceTypeRef = "cap.type.testing:0.1.0" -) - -type StorageSpec struct { - URL *string `json:"url,omitempty"` - AcceptValue *bool `json:"acceptValue,omitempty"` - ContextSchema *string `json:"contextSchema,omitempty"` -} - -// Prerequisite: -// Before running this test, make sure that Local Hub is running: -// cd hub-js; APP_NEO4J_ENDPOINT=bolt://localhost:7687 APP_NEO4J_PASSWORD=okon APP_HUB_MODE=local npm run dev -// -// To run this test, execute: -// RUN_VALIDATION=true go test ./pkg/hub/client/local/ -v -count 1 -func TestExternalStorageInputValidation(t *testing.T) { - run := os.Getenv(RunValidation) - if run == "" { - t.Skipf("skipping running example test as the env %s is not provided", RunValidation) - } - - ctx := context.Background() - cli := NewDefaultClient("http://localhost:8080/graphql") - - tests := map[string]struct { - // given - storageSpec interface{} - value map[string]interface{} - context interface{} - - // then - expErrMsg string - }{ - "Should rejected value": { - storageSpec: StorageSpec{ - URL: ptr.String("http://localhost:5000/fake"), - AcceptValue: ptr.Bool(false), - }, - value: map[string]interface{}{ - "name": "Luke Skywalker", - }, - expErrMsg: 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`), - }, - "Should rejected context": { - storageSpec: StorageSpec{ - URL: ptr.String("http://localhost:5000/fake"), - AcceptValue: ptr.Bool(false), - }, - context: map[string]interface{}{ - "name": "Luke Skywalker", - }, - expErrMsg: 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`), - }, - "Should return error that context is not an object": { - storageSpec: 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 - }`)), - }, - context: "Luke Skywalker", - expErrMsg: 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`), - }, - "Should return validation error for context": { - storageSpec: 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 - }`)), - }, - context: map[string]interface{}{ - "provider": true, - }, - expErrMsg: 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`), - }, - "Should reject value and context": { - storageSpec: StorageSpec{ - URL: ptr.String("http://localhost:5000/fake"), - AcceptValue: ptr.Bool(false), - }, - value: map[string]interface{}{ - "name": "Luke Skywalker", - }, - context: map[string]interface{}{ - "name": "Luke Skywalker", - }, - // TODO(review): currently it's a an early return, is it sufficient? - // if not, we will need to an support for throwing multierr to print aggregated data in higher layer. - expErrMsg: 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`), - }, - - // Invalid Storage TypeInstance - "Should reject usage of backend without URL field": { - storageSpec: StorageSpec{ - AcceptValue: ptr.Bool(false), - }, - value: map[string]interface{}{ - "name": "Luke Skywalker", - }, - expErrMsg: 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'`), - }, - "Should reject usage of backend without AcceptValue field": { - storageSpec: StorageSpec{ - URL: ptr.String("http://localhost:5000/fake"), - }, - value: map[string]interface{}{ - "name": "Luke Skywalker", - }, - expErrMsg: 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'`), - }, - "Should reject usage of backend without URL and AcceptValue fields": { - storageSpec: map[string]interface{}{ - "other-data": true, - }, - value: map[string]interface{}{ - "name": "Luke Skywalker", - }, - expErrMsg: 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'`), - }, - "Should reject usage of backend with wrong context schema": { - storageSpec: StorageSpec{ - URL: ptr.String("http://localhost:5000/fake"), - AcceptValue: ptr.Bool(false), - ContextSchema: ptr.String(heredoc.Doc(` - yaml: true`)), - }, - value: map[string]interface{}{ - "name": "Luke Skywalker", - }, - expErrMsg: 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`), - }, - } - for tn, tc := range tests { - t.Run(tn, func(t *testing.T) { - // given - externalStorageID, cleanup := registerExternalStorage(ctx, t, cli, tc.storageSpec) - defer cleanup() - - // when - _, err := cli.CreateTypeInstance(ctx, &gqllocalapi.CreateTypeInstanceInput{ - TypeRef: typeRef(typeInstanceTypeRef), - Value: tc.value, - Backend: &gqllocalapi.TypeInstanceBackendInput{ - ID: externalStorageID, - Context: tc.context, - }, - }) - - require.Error(t, err) - - regex := regexp.MustCompile(`\w{8}-\w{4}-\w{4}-\w{4}-\w{12}`) - gotErr := regex.ReplaceAllString(err.Error(), "MOCKED_ID") - - // then - assert.Equal(t, tc.expErrMsg, gotErr) - }) - } - - // sanity check - familyDetails, err := cli.ListTypeInstances(ctx, &gqllocalapi.TypeInstanceFilter{ - TypeRef: &gqllocalapi.TypeRefFilterInput{ - Path: typeRef(typeInstanceTypeRef).Path, - Revision: ptr.String(typeRef(typeInstanceTypeRef).Revision), - }, - }, WithFields(TypeInstanceRootFields)) - require.NoError(t, err) - assert.Len(t, familyDetails, 0) -} - -// ======= HELPERS ======= - -func registerExternalStorage(ctx context.Context, t *testing.T, cli *Client, value interface{}) (string, func()) { - t.Helper() - - externalStorageID, err := cli.CreateTypeInstance(ctx, fixExternalDotenvStorage(value)) - require.NoError(t, err) - require.NotEmpty(t, externalStorageID) - - return externalStorageID, func() { - _ = cli.DeleteTypeInstance(ctx, externalStorageID) - } -} - -func fixExternalDotenvStorage(value interface{}) *gqllocalapi.CreateTypeInstanceInput { - return &gqllocalapi.CreateTypeInstanceInput{ - TypeRef: &gqllocalapi.TypeInstanceTypeReferenceInput{ - Path: "cap.type.example.filesystem.storage", - Revision: "0.1.0", - }, - Value: value, - } -} - -func typeRef(in string) *gqllocalapi.TypeInstanceTypeReferenceInput { - out := strings.Split(in, ":") - return &gqllocalapi.TypeInstanceTypeReferenceInput{Path: out[0], Revision: out[1]} -} 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{