diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 000d73e..cc7afd7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ version: 2 updates: - - package-ecosystem: "npm" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: 'npm' # See documentation for possible values + directory: '/' # Location of package manifests schedule: - interval: "daily" + interval: 'daily' diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index a222f7c..92eb92f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -21,6 +21,7 @@ jobs: node-version: ${{ env.node-version }} cache: 'npm' - run: npm ci --prefer-offline + - run: npm run checkformat - run: npm run lint - run: npm run build - run: npm test diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d77e174..24fc461 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -9,14 +9,14 @@ # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # -name: "CodeQL" +name: 'CodeQL' on: push: - branches: [ "master" ] + branches: ['master'] pull_request: # The branches below must be a subset of the branches above - branches: [ "master" ] + branches: ['master'] schedule: - cron: '24 2 * * 3' @@ -38,45 +38,44 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'javascript' ] + language: ['javascript'] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: '/language:${{matrix.language}}' diff --git a/package-lock.json b/package-lock.json index 1e7884e..e432234 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.5.2", + "@types/backbone": "^1.4.19", "@types/basic-auth": "^1.1.3", "@types/jest": "^27.5.2", "@types/koa__cors": "^3.3.0", @@ -62,6 +63,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.32.2", + "nock": "^13.3.8", "npm-run-all": "^4.1.5", "prettier": "^3.2.5", "seedrandom": "^3.0.5", @@ -4090,6 +4092,16 @@ "@babel/types": "^7.3.0" } }, + "node_modules/@types/backbone": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.19.tgz", + "integrity": "sha512-byyn236JymGByOajKA7mi1k+/jKn162TIvArOB4SHgOGbVlFj8CSfJH4jekP0qo0vJwW5khrrsiiO1Jsos6ZvA==", + "dev": true, + "dependencies": { + "@types/jquery": "*", + "@types/underscore": "*" + } + }, "node_modules/@types/basic-auth": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@types/basic-auth/-/basic-auth-1.1.3.tgz", @@ -4290,6 +4302,15 @@ "pretty-format": "^27.0.0" } }, + "node_modules/@types/jquery": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.29.tgz", + "integrity": "sha512-oXQQC9X9MOPRrMhPHHOsXqeQDnWeCDT3PelUIg/Oy8FAbzSZtFHRjc7IpbfFVmpLtJ+UOoywpRsuO5Jxjybyeg==", + "dev": true, + "dependencies": { + "@types/sizzle": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -4513,6 +4534,12 @@ "@types/node": "*" } }, + "node_modules/@types/sizzle": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz", + "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", + "dev": true + }, "node_modules/@types/sockjs": { "version": "0.3.33", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", @@ -4571,6 +4598,12 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" }, + "node_modules/@types/underscore": { + "version": "1.11.15", + "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.11.15.tgz", + "integrity": "sha512-HP38xE+GuWGlbSRq9WrZkousaQ7dragtZCruBVMi0oX1migFZavZ3OROKHSkNp/9ouq82zrWtZpg18jFnVN96g==", + "dev": true + }, "node_modules/@types/uuid": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.2.tgz", @@ -12949,6 +12982,12 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -13778,9 +13817,15 @@ } }, "node_modules/nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -13826,6 +13871,43 @@ "tslib": "^2.0.3" } }, + "node_modules/nock": { + "version": "13.3.8", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.8.tgz", + "integrity": "sha512-96yVFal0c/W1lG7mmfRe7eO+hovrhJYd2obzzOZ90f6fjpeU/XNvd9cYHZKZAQJumDfhXgoTpkpJ9pvMj+hqHw==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">= 10.13" + } + }, + "node_modules/nock/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nock/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -14604,9 +14686,9 @@ } }, "node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "funding": [ { "type": "opencollective", @@ -14615,10 +14697,14 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -15951,6 +16037,15 @@ "react-is": "^16.13.1" } }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -22620,6 +22715,16 @@ "@babel/types": "^7.3.0" } }, + "@types/backbone": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@types/backbone/-/backbone-1.4.19.tgz", + "integrity": "sha512-byyn236JymGByOajKA7mi1k+/jKn162TIvArOB4SHgOGbVlFj8CSfJH4jekP0qo0vJwW5khrrsiiO1Jsos6ZvA==", + "dev": true, + "requires": { + "@types/jquery": "*", + "@types/underscore": "*" + } + }, "@types/basic-auth": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@types/basic-auth/-/basic-auth-1.1.3.tgz", @@ -22820,6 +22925,15 @@ "pretty-format": "^27.0.0" } }, + "@types/jquery": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.29.tgz", + "integrity": "sha512-oXQQC9X9MOPRrMhPHHOsXqeQDnWeCDT3PelUIg/Oy8FAbzSZtFHRjc7IpbfFVmpLtJ+UOoywpRsuO5Jxjybyeg==", + "dev": true, + "requires": { + "@types/sizzle": "*" + } + }, "@types/json-schema": { "version": "7.0.11", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", @@ -23043,6 +23157,12 @@ "@types/node": "*" } }, + "@types/sizzle": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.8.tgz", + "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", + "dev": true + }, "@types/sockjs": { "version": "0.3.33", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", @@ -23103,6 +23223,12 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" }, + "@types/underscore": { + "version": "1.11.15", + "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.11.15.tgz", + "integrity": "sha512-HP38xE+GuWGlbSRq9WrZkousaQ7dragtZCruBVMi0oX1migFZavZ3OROKHSkNp/9ouq82zrWtZpg18jFnVN96g==", + "dev": true + }, "@types/uuid": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.2.tgz", @@ -29158,6 +29284,12 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=" }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true + }, "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -29779,9 +29911,9 @@ } }, "nanoid": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==" + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==" }, "natural-compare": { "version": "1.4.0", @@ -29818,6 +29950,34 @@ "tslib": "^2.0.3" } }, + "nock": { + "version": "13.3.8", + "resolved": "https://registry.npmjs.org/nock/-/nock-13.3.8.tgz", + "integrity": "sha512-96yVFal0c/W1lG7mmfRe7eO+hovrhJYd2obzzOZ90f6fjpeU/XNvd9cYHZKZAQJumDfhXgoTpkpJ9pvMj+hqHw==", + "dev": true, + "requires": { + "debug": "^4.1.0", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, "node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -30369,11 +30529,11 @@ "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==" }, "postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "requires": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } @@ -31148,6 +31308,12 @@ "react-is": "^16.13.1" } }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index 0a91f14..fb7ec6f 100644 --- a/package.json +++ b/package.json @@ -43,9 +43,12 @@ "eject": "react-scripts eject", "server": "node --unhandled-rejections=warn --es-module-specifier-resolution=node build-server/server/server.js", "preserver": "npm run build:server", - "lint": "eslint --ext .js,.jsx,.ts,.tsx,.cjs .", - "fixlint": "eslint --fix --ext .js,.jsx,.ts,.tsx,.cjs .", - "format": "prettier --write \"./**/*.(js|jsx|ts|tsx|json|yml)\"" + "lint": "npm run eslint", + "fixlint": "npm run eslint -- --fix", + "eslint": "eslint --ext .js,.jsx,.ts,.tsx,.cjs .", + "format": "npm run prettier -- --write", + "checkformat": "npm run prettier -- --check", + "prettier": "prettier \"./**/*.(js|jsx|ts|tsx|json|yml)\"" }, "eslintConfig": { "extends": "react-app" @@ -68,6 +71,7 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@testing-library/user-event": "^14.5.2", + "@types/backbone": "^1.4.19", "@types/basic-auth": "^1.1.3", "@types/jest": "^27.5.2", "@types/koa__cors": "^3.3.0", @@ -87,6 +91,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react": "^7.32.2", + "nock": "^13.3.8", "npm-run-all": "^4.1.5", "prettier": "^3.2.5", "seedrandom": "^3.0.5", diff --git a/src/client/components/banner/banner.test.tsx b/src/client/components/banner/banner.test.tsx index d6c639a..b3b892e 100644 --- a/src/client/components/banner/banner.test.tsx +++ b/src/client/components/banner/banner.test.tsx @@ -1,16 +1,15 @@ -import '@testing-library/jest-dom'; import React from 'react'; import { render, screen } from '@testing-library/react'; import Banner from './banner'; describe('Banner', () => { - const envBackup = process.env + const envBackup = process.env; + + afterEach(() => (process.env = envBackup)); - afterEach(() => process.env = envBackup); - it('should render link if env var is defined', async () => { // given - process.env.REACT_APP_EOP_BANNER_TEXT = 'This is a banner text' + process.env.REACT_APP_EOP_BANNER_TEXT = 'This is a banner text'; render(); // when @@ -22,7 +21,7 @@ describe('Banner', () => { it('should not render link if env var is not defined', async () => { // given - process.env.REACT_APP_EOP_BANNER_TEXT = ""; + process.env.REACT_APP_EOP_BANNER_TEXT = ''; render(); // when diff --git a/src/client/components/banner/banner.tsx b/src/client/components/banner/banner.tsx index 8f548c4..5edddba 100644 --- a/src/client/components/banner/banner.tsx +++ b/src/client/components/banner/banner.tsx @@ -2,14 +2,13 @@ import React from 'react'; import type { FC } from 'react'; import './banner.css'; - const Banner: FC = () => { if (process.env.REACT_APP_EOP_BANNER_TEXT) { return ( -
{process.env.REACT_APP_EOP_BANNER_TEXT}
- ); +
{process.env.REACT_APP_EOP_BANNER_TEXT}
+ ); } return null; -} +}; export default Banner; diff --git a/src/client/components/board/board.jsx b/src/client/components/board/board.jsx deleted file mode 100644 index bb1de01..0000000 --- a/src/client/components/board/board.jsx +++ /dev/null @@ -1,205 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Model from '../model/model'; -import Deck from '../deck/deck'; -import Sidebar from '../sidebar/sidebar'; -import Threatbar from '../threatbar/threatbar'; -import ImageModel from '../imagemodel/imagemodel'; -import Timer from '../timer/timer'; -import './board.css'; -import request from 'superagent'; -import Status from '../status/status'; -import { getDealtCard } from '../../../utils/utils'; -import { ModelType, SPECTATOR } from '../../../utils/constants'; -import LicenseAttribution from '../license/licenseAttribution'; -import { API_PORT } from '../../../utils/serverConfig'; -import PrivacyEnhancedModel from '../privacyEnhancedModel/privacyEnhancedModel'; -import Imprint from '../footer/imprint'; -import Privacy from '../footer/privacy'; -import Banner from '../banner/banner'; - -class Board extends React.Component { - static get propTypes() { - return { - G: PropTypes.any.isRequired, - ctx: PropTypes.any.isRequired, - matchID: PropTypes.any.isRequired, - moves: PropTypes.any, - events: PropTypes.any, - playerID: PropTypes.any, - credentials: PropTypes.string, - }; - } - - constructor(props) { - super(props); - let names = []; - for (let i = 0; i < this.props.ctx.numPlayers; i++) { - names.push('No Name'); - } - this.state = { - names, - model: null, - }; - this.apiBase = - process.env.NODE_ENV === 'production' - ? '/api' - : `${window.location.protocol}//${window.location.hostname}:${API_PORT}`; - } - - updateName(index, name) { - this.setState({ - ...this.state, - names: { - ...this.state.names, - [index]: name, - }, - }); - } - - async apiGetRequest(endpoint) { - // Using superagent makes auth easier but for consistency using fetch may be better - try { - return await request - .get(`${this.apiBase}/game/${this.props.matchID}/${endpoint}`) - .auth(this.props.playerID ?? SPECTATOR, this.props.credentials); - } catch (err) { - console.error(err); - } - } - - async updateNames() { - const g = await this.apiGetRequest('players'); - g?.body.players.forEach((p) => { - if (typeof p.name !== 'undefined') { - this.updateName(p.id, p.name); - } - }); - } - - async updateModel() { - const r = await this.apiGetRequest('model'); - - const model = r?.body; - - this.setState({ - ...this.state, - model, - }); - } - - componentDidMount() { - this.updateNames(); - if (this.props.G.modelType !== ModelType.IMAGE) { - this.updateModel(); - } - } - - render() { - const current = this.props.playerID === this.props.ctx.currentPlayer; - const isInThreatStage = - this.props.ctx.activePlayers && - this.props.ctx.activePlayers[this.props.playerID] === 'threats' - ? true - : false; - - const isSpectator = !this.props.playerID; - const isFirstPlayerInThreatStage = this.props.ctx.activePlayers?.[0] === 'threats'; - - const shouldShowTimer = isInThreatStage || (isSpectator && isFirstPlayerInThreatStage); - const active = current || isInThreatStage; - - let dealtCard = getDealtCard(this.props.G); - - return ( -
- - {this.props.G.modelType === ModelType.IMAGE && ( - - )} - {this.props.G.modelType === ModelType.THREAT_DRAGON && ( - - )} - {this.props.G.modelType === ModelType.PRIVACY_ENHANCED && ( - - )} -
-
-
- -
- {this.props.playerID && ( - this.props.moves.draw(e)} - startingCard={this.props.G.startingCard} // <=== This is still missing i.e. undeifned - gameMode={this.props.G.gameMode} - /> - )} -
-
- - -
- -
- - - -
- ); - } -} - -export default Board; diff --git a/src/client/components/board/board.test.tsx b/src/client/components/board/board.test.tsx index f47d2bf..6ebe7b0 100644 --- a/src/client/components/board/board.test.tsx +++ b/src/client/components/board/board.test.tsx @@ -1,24 +1,32 @@ -import '@testing-library/jest-dom'; import React from 'react'; import { render, screen } from '@testing-library/react'; import Board from './board'; -import { DEFAULT_TURN_DURATION } from '../../../utils/constants'; +import { DEFAULT_TURN_DURATION, ModelType } from '../../../utils/constants'; import { DEFAULT_GAME_MODE } from '../../../utils/GameMode'; +import type { GameState } from '../../../game/gameState'; +import type { Ctx } from 'boardgame.io'; +import nock from 'nock'; -jest.mock('../model/model.jsx'); +import { API_PORT } from '../../../utils/serverConfig'; -const G = { +const baseUrl = `${window.location.protocol}//${window.location.hostname}:${API_PORT}`; + +beforeAll(() => { + nock(baseUrl) + .get('/players') + .reply(200, { players: [{ id: 0, name: 'Player 0' }] }); +}); + +const G: GameState = { dealt: [], - players: { - 0: ['T3', 'T4', 'T5'], - }, - order: [0, 1, 2], + players: [['T3', 'T4', 'T5']], scores: [0, 0, 0], - identifiedThreats: {}, + identifiedThreats: [], selectedDiagram: 0, selectedComponent: '', threat: { modal: false, + new: false, }, passed: [], suit: 'T', @@ -27,84 +35,128 @@ const G = { gameMode: DEFAULT_GAME_MODE, turnDuration: DEFAULT_TURN_DURATION, turnFinishTargetTime: Date.now() + DEFAULT_TURN_DURATION * 1000, + dealtBy: '', + numCardsPlayed: 0, + lastWinner: 0, + maxRounds: 10, + selectedThreat: 'some-threat', + modelType: ModelType.IMAGE, }; const ctx = { - actionPlayers: [0, 1, 2], -}; -// Suppress REST calls during test -Board.prototype.apiGetRequest = jest.fn(); + numPlayers: 1, + currentPlayer: '0', + activePlayers: { '0': 'threats' }, +} as unknown as Ctx; describe('Board', () => { - it('renders without crashing', async () => { - + it('renders without crashing', () => { // when - render(); + render( + , + ); // then - await screen.getByRole(`button`, { - name: `Download Threats` + screen.getByRole(`button`, { + name: `Download Threats`, }); - }); - it('should render imprint link if env var is defined', async () => { + it('should render imprint link if env var is defined', () => { // given - process.env.REACT_APP_EOP_IMPRINT = 'https://example.tld/imprint/' - - // when - render(); + process.env.REACT_APP_EOP_IMPRINT = 'https://example.tld/imprint/'; // when + render( + , + ); // then - const links = await screen.queryAllByRole('link', { - name: `Imprint` + const links = screen.queryAllByRole('link', { + name: `Imprint`, }); expect(links.length).toBe(1); }); - it('should not render imprint link if env var is not defined', async () => { + it('should not render imprint link if env var is not defined', () => { // given - process.env.REACT_APP_EOP_IMPRINT = ""; + process.env.REACT_APP_EOP_IMPRINT = ''; // when - render(); + render( + , + ); // then - const links = await screen.queryAllByRole('link', { - name: `Imprint` + const links = screen.queryAllByRole('link', { + name: `Imprint`, }); expect(links.length).toBe(0); }); - it('should render privacy link if env var is defined', async () => { + it('should render privacy link if env var is defined', () => { // given - process.env.REACT_APP_EOP_PRIVACY = 'https://example.tld/privacy/' - - // when - render(); + process.env.REACT_APP_EOP_PRIVACY = 'https://example.tld/privacy/'; // when + render( + , + ); // then - const links = await screen.queryAllByRole('link', { - name: `Privacy` + const links = screen.queryAllByRole('link', { + name: `Privacy`, }); expect(links.length).toBe(1); }); - it('should not render privacy link if env var is not defined', async () => { + it('should not render privacy link if env var is not defined', () => { // given - process.env.REACT_APP_EOP_PRIVACY = ""; + process.env.REACT_APP_EOP_PRIVACY = ''; // when - render(); + render( + , + ); // then - const links = await screen.queryAllByRole('link', { - name: `Privacy` + const links = screen.queryAllByRole('link', { + name: `Privacy`, }); expect(links.length).toBe(0); }); - }); diff --git a/src/client/components/board/board.tsx b/src/client/components/board/board.tsx new file mode 100644 index 0000000..9cb9271 --- /dev/null +++ b/src/client/components/board/board.tsx @@ -0,0 +1,215 @@ +import type { BoardProps as BoardgameIOBoardProps } from 'boardgame.io/react'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import Model from '../model/model'; +import Deck from '../deck/deck'; +import Sidebar from '../sidebar/sidebar'; +import Threatbar from '../threatbar/threatbar'; +import ImageModel from '../imagemodel/imagemodel'; +import Timer from '../timer/timer'; +import './board.css'; +import request from 'superagent'; +import Status from '../status/status'; +import { getDealtCard } from '../../../utils/utils'; +import { ModelType, SPECTATOR } from '../../../utils/constants'; +import LicenseAttribution from '../license/licenseAttribution'; +import { API_PORT } from '../../../utils/serverConfig'; +import PrivacyEnhancedModel from '../privacyEnhancedModel/privacyEnhancedModel'; +import Imprint from '../footer/imprint'; +import Privacy from '../footer/privacy'; +import Banner from '../banner/banner'; +import type { GameState } from '../../../game/gameState'; +import type { ThreatDragonModel } from '../../../types/ThreatDragonModel'; + +type BoardProps = Pick< + BoardgameIOBoardProps, + 'G' | 'ctx' | 'matchID' | 'moves' | 'playerID' | 'credentials' +>; + +const Board: FC = ({ + G, + ctx, + matchID, + moves, + playerID, + credentials, +}) => { + const initialNames = Array.from({ + length: ctx.numPlayers, + }).fill('No Name'); + + const [names, setNames] = useState(initialNames); + + const [model, setModel] = useState(undefined); + const apiBase = + process.env.NODE_ENV === 'production' + ? '/api' + : `${window.location.protocol}//${window.location.hostname}:${API_PORT}`; + + const updateName = useCallback((index: number, name: string) => { + setNames((names) => { + const newNames = [...names]; + newNames[index] = name; + return newNames; + }); + }, []); + + const apiGetRequest = useCallback( + async (endpoint: string) => { + // Using superagent makes auth easier but for consistency using fetch may be better + if (credentials !== undefined) { + try { + return await request + .get(`${apiBase}/game/${matchID}/${endpoint}`) + .auth(playerID ?? SPECTATOR, credentials); + } catch (err) { + console.error(err); + } + } else { + console.error('Credentials are missing.'); + } + }, + [apiBase, matchID, playerID, credentials], + ); + + const updateNames = useCallback(async () => { + // TODO: Type with zod and consider using react-query. + const playersResponse = await apiGetRequest('players'); + for (const player of playersResponse?.body.players ?? []) { + if (typeof player.name !== 'undefined') { + updateName(player.id, player.name); + } + } + }, [apiGetRequest, updateName]); + + const updateModel = useCallback(async () => { + // TODO: Type with zod and consider using react-query. + const modelResponse = await apiGetRequest('model'); + + const model = modelResponse?.body as ThreatDragonModel | undefined; + + setModel(model); + }, [apiGetRequest]); + + // consider using react-query instead + useEffect(() => { + if (G.modelType !== ModelType.IMAGE) { + updateModel(); + } + }, [G.modelType, updateModel]); + + useEffect(() => { + updateNames(); + }, [updateNames]); + + const current = playerID === ctx.currentPlayer; + + const isInThreatStage = + !!ctx.activePlayers && + !!playerID && + ctx.activePlayers?.[playerID] === 'threats'; + + const isSpectator = !playerID; + const isFirstPlayerInThreatStage = ctx.activePlayers?.[0] === 'threats'; + + const shouldShowTimer = + isInThreatStage || (isSpectator && isFirstPlayerInThreatStage); + const active = current || isInThreatStage; + + const dealtCard = getDealtCard(G); + + if (credentials === undefined) { + return ( +
+ +

Credentials are missing.

+
+ ); + } + + return ( +
+ + + {G.modelType === ModelType.IMAGE && ( + + )} + {G.modelType === ModelType.THREAT_DRAGON && model !== undefined && ( + + )} + {G.modelType === ModelType.PRIVACY_ENHANCED && ( + + )} +
+
+
+ +
+ {playerID && ( + moves.draw(e)} + startingCard={G.startingCard} // <=== This is still missing i.e. undeifned + gameMode={G.gameMode} + /> + )} +
+
+ + +
+ +
+ + + +
+ ); +}; + +export default Board; diff --git a/src/client/components/copybutton/copybutton.test.tsx b/src/client/components/copybutton/copybutton.test.tsx index 0ef9ad7..afc4ade 100644 --- a/src/client/components/copybutton/copybutton.test.tsx +++ b/src/client/components/copybutton/copybutton.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import React from 'react'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; diff --git a/src/client/components/deck/deck.tsx b/src/client/components/deck/deck.tsx index 022e868..09d9ad7 100644 --- a/src/client/components/deck/deck.tsx +++ b/src/client/components/deck/deck.tsx @@ -4,7 +4,7 @@ import { GameMode, getCardCssClass } from '../../../utils/GameMode'; import type { Card, Suit } from '../../../utils/cardDefinitions'; interface DeckProps { - suit: Suit; + suit?: Suit; cards: Card[]; isInThreatStage: boolean; round: number; diff --git a/src/client/components/downloadbutton/downloadbutton.test.tsx b/src/client/components/downloadbutton/downloadbutton.test.tsx index bd7570d..fd4115a 100644 --- a/src/client/components/downloadbutton/downloadbutton.test.tsx +++ b/src/client/components/downloadbutton/downloadbutton.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import React from 'react'; import { render, screen } from '@testing-library/react'; diff --git a/src/client/components/downloadbutton/downloadbutton.tsx b/src/client/components/downloadbutton/downloadbutton.tsx index 3e8a99b..6e071d8 100644 --- a/src/client/components/downloadbutton/downloadbutton.tsx +++ b/src/client/components/downloadbutton/downloadbutton.tsx @@ -42,8 +42,7 @@ const DownloadButton: FC = ({ try { const res = await fetch(apiEndpointUrl, { headers: { - Authorization: - 'Basic ' + btoa(playerID + ':' + secret), + Authorization: 'Basic ' + btoa(playerID + ':' + secret), }, }); if (!res.ok) { diff --git a/src/client/components/footer/footer.test.tsx b/src/client/components/footer/footer.test.tsx index adf5042..bd1af40 100644 --- a/src/client/components/footer/footer.test.tsx +++ b/src/client/components/footer/footer.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import React from 'react'; import { render, screen } from '@testing-library/react'; import Footer from './footer'; diff --git a/src/client/components/footer/footer.tsx b/src/client/components/footer/footer.tsx index 64a87c0..ba83daf 100644 --- a/src/client/components/footer/footer.tsx +++ b/src/client/components/footer/footer.tsx @@ -7,7 +7,6 @@ import Imprint from './imprint'; import './footer.css'; import Privacy from './privacy'; - type FooterProps = { short?: boolean; }; @@ -24,16 +23,16 @@ const Footer: FC = ({ short }) => ( at Careem and{' '} TNG Technology Consulting - - Elevation of Privilege was originally invented at Microsoft, Cornucopia - was developed at OWASP, Cumulus was started at{' '} - TNG Technology Consulting, Elevation of MLsec was developed at {' '} + Elevation of Privilege was originally invented at Microsoft, + Cornucopia was developed at OWASP, Cumulus was started at{' '} + TNG Technology Consulting, + Elevation of MLsec was developed at{' '} Kantega AS. -
- - +
+ +
- )} diff --git a/src/client/components/footer/imprint.test.tsx b/src/client/components/footer/imprint.test.tsx index 13e72e3..209d18d 100644 --- a/src/client/components/footer/imprint.test.tsx +++ b/src/client/components/footer/imprint.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import React from 'react'; import { render, screen } from '@testing-library/react'; import Imprint from './imprint'; @@ -6,7 +5,7 @@ import Imprint from './imprint'; describe('Imprint', () => { it('should render link if env var is defined', async () => { // given - process.env.REACT_APP_EOP_IMPRINT = 'https://example.tld/imprint/' + process.env.REACT_APP_EOP_IMPRINT = 'https://example.tld/imprint/'; render(); // when @@ -18,7 +17,7 @@ describe('Imprint', () => { it('should not render link if env var is not defined', async () => { // given - process.env.REACT_APP_EOP_IMPRINT = ""; + process.env.REACT_APP_EOP_IMPRINT = ''; render(); // when diff --git a/src/client/components/footer/imprint.tsx b/src/client/components/footer/imprint.tsx index 5626da0..863e758 100644 --- a/src/client/components/footer/imprint.tsx +++ b/src/client/components/footer/imprint.tsx @@ -3,12 +3,9 @@ import type { FC } from 'react'; const Imprint: FC = () => { if (process.env.REACT_APP_EOP_IMPRINT) { - return ( - Imprint - ); + return Imprint; } return null; - -} +}; export default Imprint; diff --git a/src/client/components/footer/privacy.test.tsx b/src/client/components/footer/privacy.test.tsx index bd9b7d7..36a8a42 100644 --- a/src/client/components/footer/privacy.test.tsx +++ b/src/client/components/footer/privacy.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import React from 'react'; import { render, screen } from '@testing-library/react'; import Privacy from './privacy'; @@ -6,7 +5,7 @@ import Privacy from './privacy'; describe('Privacy', () => { it('should render link if env var is defined', async () => { // given - process.env.REACT_APP_EOP_PRIVACY = 'https://example.tld/privacy/' + process.env.REACT_APP_EOP_PRIVACY = 'https://example.tld/privacy/'; render(); // when @@ -18,7 +17,7 @@ describe('Privacy', () => { it('should not render link if env var is not defined', async () => { // given - process.env.REACT_APP_EOP_PRIVACY = ""; + process.env.REACT_APP_EOP_PRIVACY = ''; render(); // when diff --git a/src/client/components/footer/privacy.tsx b/src/client/components/footer/privacy.tsx index 05ff9bc..e596da2 100644 --- a/src/client/components/footer/privacy.tsx +++ b/src/client/components/footer/privacy.tsx @@ -3,11 +3,9 @@ import type { FC } from 'react'; const Privacy: FC = () => { if (process.env.REACT_APP_EOP_PRIVACY) { - return ( - Privacy - ); - } - return null; -} + return Privacy; + } + return null; +}; export default Privacy; diff --git a/src/client/components/imagemodel/imagemodel.tsx b/src/client/components/imagemodel/imagemodel.tsx index 3921b6e..da7c9b0 100644 --- a/src/client/components/imagemodel/imagemodel.tsx +++ b/src/client/components/imagemodel/imagemodel.tsx @@ -26,9 +26,7 @@ const ImageModel: FC = ({ const updateImage = useCallback(async () => { const res = await fetch(`${apiBase}/game/${matchID}/image`, { headers: { - Authorization: - 'Basic ' + - btoa(playerID + ':' + credentials), + Authorization: 'Basic ' + btoa(playerID + ':' + credentials), }, }); if (!res.ok) { diff --git a/src/client/components/leaderboard/leaderboard.test.tsx b/src/client/components/leaderboard/leaderboard.test.tsx index 8dd55bf..a5f660b 100644 --- a/src/client/components/leaderboard/leaderboard.test.tsx +++ b/src/client/components/leaderboard/leaderboard.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import React from 'react'; import Leaderboard from './leaderboard'; import { DEFAULT_GAME_MODE } from '../../../utils/GameMode'; diff --git a/src/client/components/leaderboard/leaderboard.tsx b/src/client/components/leaderboard/leaderboard.tsx index d027d4f..e125a21 100644 --- a/src/client/components/leaderboard/leaderboard.tsx +++ b/src/client/components/leaderboard/leaderboard.tsx @@ -10,9 +10,9 @@ import type { GameMode } from '../../../utils/GameMode'; import './leaderboard.css'; type LeaderboardProps = { - scores: Array; - names: Array; - cards: Array; + scores: number[]; + names: string[]; + cards: (EOPCard | null)[]; playerID: PlayerID; passedUsers: Array; gameMode: GameMode; @@ -57,7 +57,9 @@ const Leaderboard: FC = ({ {hasPassed(idx) &&
} - {getCardDisplayName(gameMode, cards[idx])} + + {getCardDisplayName(gameMode, cards[idx] ?? undefined)} + {val} diff --git a/src/client/components/license/licenseAttribution.tsx b/src/client/components/license/licenseAttribution.tsx index 22b6fb0..d90c79e 100644 --- a/src/client/components/license/licenseAttribution.tsx +++ b/src/client/components/license/licenseAttribution.tsx @@ -43,7 +43,8 @@ const LicenseAttribution: React.FC = ({ case GameMode.CUMULUS: return (
- The card game OWASP Cumulus by{' '} + The card game{' '} + OWASP Cumulus by{' '} TNG Technology Consulting {' '} @@ -52,18 +53,20 @@ const LicenseAttribution: React.FC = ({
); - case GameMode.EOMLSEC: - return ( -
- The card game Elevation of MLsec by{' '} - - Kantega AS + case GameMode.EOMLSEC: + return ( +
+ The card game{' '} + + Elevation of MLsec {' '} - is licensed under{' '} - CC BY-SA 4.0 DEED. + by Kantega AS is licensed under{' '} + + CC BY-SA 4.0 DEED + + .
- ); - + ); default: return <>; diff --git a/src/client/components/model/model.jsx b/src/client/components/model/model.jsx deleted file mode 100644 index 6161b36..0000000 --- a/src/client/components/model/model.jsx +++ /dev/null @@ -1,212 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import * as joint from 'jointjs'; -import 'jointjs/dist/joint.css'; -import '../../jointjs/joint-tm.css'; -// eslint-disable-next-line -import '../../jointjs/shapes'; -import { Nav, NavItem, NavLink } from 'reactstrap'; -import classnames from 'classnames'; -import './model.css'; -import Helmet from 'react-helmet'; - -const SPEED = 20; - -class Model extends React.Component { - static get propTypes() { - return { - model: PropTypes.any, - selectedDiagram: PropTypes.number.isRequired, - selectedComponent: PropTypes.string.isRequired, - onSelectDiagram: PropTypes.func.isRequired, - onSelectComponent: PropTypes.func.isRequired, - }; - } - - constructor(props) { - super(props); - this.state = { - placeholder: React.createRef(), - graph: new joint.dia.Graph({}, { cellNamespace: joint.shapes }), - paper: null, - dragging: false, - dragPosition: { - x: 0, - y: 0, - }, - }; - this.mouseMove = this.mouseMove.bind(this); - this.mouseWheel = this.mouseWheel.bind(this); - } - - offsetToLocalPoint(offsetX, offsetY, paper) { - // Finds mouse position in unscaled version - var svgPoint = paper.svg.createSVGPoint(); - svgPoint.x = offsetX; - svgPoint.y = offsetY; - var offsetTransformed = svgPoint.matrixTransform( - paper.layers.getCTM().inverse(), - ); - return offsetTransformed; - } - - mouseWheel(e) { - e = e.nativeEvent; - var delta = Math.max(-1, Math.min(1, e.wheelDelta)) / SPEED; - var newScale = joint.V(this.state.paper.layers).scale().sx + delta; - this.state.paper.translate(0, 0); - var p = this.offsetToLocalPoint(e.offsetX, e.offsetY, this.state.paper); - this.state.paper.scale(newScale, newScale, p.x, p.y); - } - - mouseMove(e) { - if (this.state.dragging) { - const x = e.nativeEvent.offsetX; - const y = e.nativeEvent.offsetY; - this.state.paper.translate( - x - this.state.dragPosition.x, - y - this.state.dragPosition.y, - ); - } - } - - componentDidUpdate(prevProps) { - if ( - this.props.model !== prevProps.model || - this.props.selectedDiagram !== prevProps.selectedDiagram - ) { - this.updateDiagram(this.state.paper); - this.updateSelection(this.state.paper); - } - - if (this.props.selectedComponent !== prevProps.selectedComponent) { - this.updateSelection(this.state.paper); - } - } - - updateSelection(paper) { - // unhighlight all - for (var k in paper._views) { - // eslint-disable-next-line no-prototype-builtins - if (paper._views.hasOwnProperty(k)) { - paper._views[k].unhighlight(); - } - } - - // highlight the selected component - if (this.props.selectedComponent in paper._views) { - paper._views[this.props.selectedComponent].highlight(); - } - } - - updateDiagram(paper) { - if (this.props.model !== null && paper !== null) { - this.state.graph.fromJSON( - this.props.model.detail.diagrams[this.props.selectedDiagram] - .diagramJson, - ); - //paper.fitToContent(1, 1, 10, { allowNewOrigin: "any" }); - } - } - - componentDidMount() { - let paper = new joint.dia.Paper({ - el: this.state.placeholder.current, - width: window.innerWidth, - height: window.innerHeight, - model: this.state.graph, - interactive: false, - gridSize: 10, - drawGrid: true, - }); - - this.setState({ - ...this.state, - paper, - }); - - // setup callbacks - let parent = this; - paper.on('cell:pointerclick', function (cellView) { - if (cellView.model.attributes.type === 'tm.Boundary') return; - - parent.props.onSelectComponent(cellView.model.id); - }); - paper.on('blank:pointerclick', function () { - if (parent.props.selectedComponent !== '') { - parent.props.onSelectComponent(''); - } - }); - paper.on('blank:pointerdown', function (event, x, y) { - parent.setState({ - ...parent.state, - dragging: true, - dragPosition: { - x, - y, - }, - }); - }); - paper.on('cell:pointerup blank:pointerup', function (event, x, y) { - parent.setState({ - ...parent.state, - dragging: false, - dragPosition: { - x, - y, - }, - }); - }); - - // initial render - this.updateDiagram(paper); - this.updateSelection(paper); - } - - render() { - let content =
; - - if (this.props.model === null) { - content =

No Model

; - } else { - content = ( -
- - EoP - {this.props.model.summary.title} - -

- {this.props.model.summary.title} -

- -
- ); - } - - return ( -
- {content} -
-
- ); - } -} - -export default Model; diff --git a/src/client/components/model/model.tsx b/src/client/components/model/model.tsx new file mode 100644 index 0000000..930de6d --- /dev/null +++ b/src/client/components/model/model.tsx @@ -0,0 +1,209 @@ +import React, { FC, useCallback, useEffect, useState } from 'react'; +import * as joint from 'jointjs'; +import 'jointjs/dist/joint.css'; +import '../../jointjs/joint-tm.css'; +import '../../jointjs/shapes'; +import { Nav, NavItem, NavLink } from 'reactstrap'; +import classnames from 'classnames'; +import './model.css'; +import Helmet from 'react-helmet'; +import type { ThreatDragonModel } from '../../../types/ThreatDragonModel'; + +const SCROLL_SPEED = 1000; + +type ModelProps = { + model: ThreatDragonModel; + selectedDiagram: number; + selectedComponent: string; + onSelectDiagram: (id: number) => void; + onSelectComponent: (id: string) => void; +}; + +const Model: FC = ({ + model, + selectedDiagram, + selectedComponent, + onSelectDiagram, + onSelectComponent, +}) => { + const [graph] = useState( + new joint.dia.Graph({}, { cellNamespace: joint.shapes }), + ); + + const createPaper = useCallback( + (el?: HTMLElement) => + new joint.dia.Paper({ + el, + width: window.innerWidth, + height: window.innerHeight, + model: graph, + interactive: false, + gridSize: 10, + drawGrid: true, + }), + [graph], + ); + + const [paper, setPaper] = useState(createPaper()); + + const placeholderRef = useCallback( + (el: HTMLElement | null) => { + if (el !== null) { + setPaper(createPaper(el)); + } + }, + [createPaper], + ); + + const [dragging, setDragging] = useState(false); + const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); + + useEffect(() => { + graph.fromJSON(model.detail.diagrams[selectedDiagram].diagramJson); + //paper.fitToContent(1, 1, 10, { allowNewOrigin: "any" }); + }, [graph, model, selectedDiagram]); + + useEffect(() => { + // unhighlight all + paper.model.getElements().forEach((e) => { + paper.findViewByModel(e).unhighlight(); + }); + paper.model.getLinks().forEach((e) => { + paper.findViewByModel(e).unhighlight(); + }); + + // highlight the selected component + const selectedComponentView = paper.findViewByModel(selectedComponent); + if (selectedComponentView) { + selectedComponentView.highlight(); + } + }, [paper, selectedComponent]); + + useEffect(() => { + const onCellPointerClick = (cellView: joint.dia.CellView) => { + if (cellView.model.attributes.type !== 'tm.Boundary') { + onSelectComponent(cellView.model.id.toString()); + } + }; + + const onBlankPointerClick = () => { + onSelectComponent(''); + }; + + const setDragPositionScaled = (x: number, y: number) => { + const scale = joint.V(paper.layers).scale(); + setDragPosition({ x: x * scale.sx, y: y * scale.sy }); + }; + + const onBlankPointerDown = ( + event: joint.dia.Event, + x: number, + y: number, + ) => { + setDragging(true); + setDragPositionScaled(x, y); + }; + + const stopDragging = (x: number, y: number) => { + setDragging(false); + setDragPositionScaled(x, y); + }; + + const onCellPointerUp = ( + cellView: joint.dia.CellView, + event: joint.dia.Event, + x: number, + y: number, + ) => { + stopDragging(x, y); + }; + + const onBlankPointerUp = (event: joint.dia.Event, x: number, y: number) => { + stopDragging(x, y); + }; + + paper.on('cell:pointerclick', onCellPointerClick); + paper.on('blank:pointerclick', onBlankPointerClick); + paper.on('blank:pointerdown', onBlankPointerDown); + paper.on('cell:pointerup', onCellPointerUp); + paper.on('blank:pointerup', onBlankPointerUp); + + return () => { + paper.off('cell:pointerclick', onCellPointerClick); + paper.off('blank:pointerclick', onBlankPointerClick); + paper.off('blank:pointerdown', onBlankPointerDown); + paper.off('cell:pointerup', onCellPointerUp); + paper.off('blank:pointerup', onBlankPointerUp); + }; + }, [paper, onSelectComponent]); + + const mouseWheel = useCallback( + ({ nativeEvent: e }: React.WheelEvent) => { + const delta = -e.deltaY / SCROLL_SPEED; + const newScale = joint.V(paper.layers).scale().sx + delta; + paper.translate(0, 0); + const p = offsetToLocalPoint(e.offsetX, e.offsetY, paper); + paper.scale(newScale, newScale, p.x, p.y); + }, + [paper], + ); + + const mouseMove = useCallback( + ({ nativeEvent: e }: React.MouseEvent) => { + if (dragging) { + const x = e.offsetX; + const y = e.offsetY; + paper.translate(x - dragPosition.x, y - dragPosition.y); + } + }, + [dragging, paper, dragPosition.x, dragPosition.y], + ); + + return ( +
+
+ + EoP - {model.summary.title} + +

{model.summary.title}

+ +
+
+
+ ); +}; + +export default Model; + +const offsetToLocalPoint = ( + offsetX: number, + offsetY: number, + paper: joint.dia.Paper, +) => { + // Finds mouse position in unscaled version + const svgPoint = paper.svg.createSVGPoint(); + svgPoint.x = offsetX; + svgPoint.y = offsetY; + const offsetTransformed = svgPoint.matrixTransform( + paper.layers.getCTM()?.inverse(), + ); + return offsetTransformed; +}; diff --git a/src/client/components/sidebar/sidebar.test.tsx b/src/client/components/sidebar/sidebar.test.tsx index 8f46bd5..2951e16 100644 --- a/src/client/components/sidebar/sidebar.test.tsx +++ b/src/client/components/sidebar/sidebar.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import React from 'react'; import Sidebar from './sidebar'; import { DEFAULT_GAME_MODE } from '../../../utils/GameMode'; @@ -28,7 +27,7 @@ describe('Sidebar', () => { modal: false, new: true, }, - identifiedThreats: {}, + identifiedThreats: [], startingCard: 'the starting card', gameMode: DEFAULT_GAME_MODE, turnDuration: 0, diff --git a/src/client/components/status/status.jsx b/src/client/components/status/status.jsx deleted file mode 100644 index 5c899d6..0000000 --- a/src/client/components/status/status.jsx +++ /dev/null @@ -1,91 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { getCardDisplayName } from '../../../utils/cardDefinitions'; -import { - getPlayers, - grammarJoin, - resolvePlayerName, - resolvePlayerNames, -} from '../../../utils/utils'; -import './status.css'; - -class Status extends React.Component { - static get propTypes() { - return { - playerID: PropTypes.any, - G: PropTypes.any.isRequired, - ctx: PropTypes.any.isRequired, - current: PropTypes.bool.isRequired, - active: PropTypes.bool.isRequired, - names: PropTypes.any.isRequired, - dealtCard: PropTypes.string.isRequired, - isInThreatStage: PropTypes.bool, - }; - } - - static get defaultProps() { - return { - isInThreatStage: false, - }; - } - - render() { - const isSpectator = !this.props.playerID; - const isFirstPlayerInThreatStage = this.props.ctx.activePlayers?.[0] === 'threats'; - - if (!(this.props.isInThreatStage || (isSpectator && isFirstPlayerInThreatStage))) { - let currentPlayerName = resolvePlayerName( - this.props.ctx.currentPlayer, - this.props.names, - this.props.playerID, - ); - let prefix = ; - - if (this.props.dealtCard === '' && this.props.G.round > 1) { - let winnerName = resolvePlayerName( - this.props.G.lastWinner, - this.props.names, - this.props.playerID, - ); - prefix = ( - - Last round won by {winnerName}.{' '} - - ); - } - - return ( - - {prefix}Waiting for {currentPlayerName} to play a - card. - - ); - } else { - let all = new Set(getPlayers(this.props.ctx.numPlayers)); - let passed = new Set(this.props.G.passed); - let difference = new Set([...all].filter((x) => !passed.has(x))); - let players = resolvePlayerNames( - Array.from(difference), - this.props.names, - this.props.playerID, - ); - let playerWhoDealt = resolvePlayerName( - this.props.G.dealtBy, - this.props.names, - this.props.playerID, - ); - - return ( - - {playerWhoDealt} dealt{' '} - - {getCardDisplayName(this.props.G.gameMode, this.props.dealtCard)} - - , waiting for {grammarJoin(players)} to add threats - or pass. - - ); - } - } -} -export default Status; diff --git a/src/client/components/status/status.test.js b/src/client/components/status/status.test.js deleted file mode 100644 index 01e4049..0000000 --- a/src/client/components/status/status.test.js +++ /dev/null @@ -1,118 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import Status from './status'; -import { DEFAULT_GAME_MODE } from '../../../utils/GameMode'; - -it('renders without crashing', () => { - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - gameMode: DEFAULT_GAME_MODE, - }; - const ctx = {}; - const div = document.createElement('div'); - ReactDOM.render( - , - div, - ); - ReactDOM.unmountComponentAtNode(div); -}); - -it('renders play phase correctly', () => { - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - round: 0, - passed: [], - gameMode: DEFAULT_GAME_MODE, - }; - const ctx = { - phase: 'play', - currentPlayer: '0', - numPlayers: 3, - }; - const div = document.createElement('div'); - ReactDOM.render( - , - div, - ); - ReactDOM.unmountComponentAtNode(div); -}); - -it('renders threat phase correctly', () => { - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - round: 0, - passed: [], - gameMode: DEFAULT_GAME_MODE, - }; - const ctx = { - phase: 'threats', - currentPlayer: '0', - numPlayers: 3, - }; - const div = document.createElement('div'); - ReactDOM.render( - , - div, - ); - ReactDOM.unmountComponentAtNode(div); -}); - -it('renders last won correctly', () => { - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - round: 10, - passed: [], - dealtCard: '', - gameMode: DEFAULT_GAME_MODE, - }; - const ctx = { - phase: 'play', - currentPlayer: '0', - numPlayers: 3, - }; - const div = document.createElement('div'); - ReactDOM.render( - , - div, - ); - ReactDOM.unmountComponentAtNode(div); -}); diff --git a/src/client/components/status/status.test.tsx b/src/client/components/status/status.test.tsx new file mode 100644 index 0000000..01bd1ee --- /dev/null +++ b/src/client/components/status/status.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import Status from './status'; +import { DEFAULT_GAME_MODE } from '../../../utils/GameMode'; +import { render, screen } from '@testing-library/react'; +import type { Ctx } from 'boardgame.io'; +import type { GameState } from '../../../game/gameState'; +import { ModelType } from '../../../utils/constants'; + +describe('Status', () => { + const G: GameState = { + dealt: ['T2'], + players: [[]], + scores: [0, 0, 0], + gameMode: DEFAULT_GAME_MODE, + passed: [], + suit: undefined, + dealtBy: '0', + round: 0, + numCardsPlayed: 0, + lastWinner: 0, + maxRounds: 0, + selectedDiagram: 0, + selectedComponent: '', + selectedThreat: '', + threat: { + modal: false, + new: false, + }, + identifiedThreats: [], + startingCard: '', + turnDuration: 0, + modelType: ModelType.IMAGE, + }; + + it('renders waiting for player to play a card text in play stage', async () => { + // given + const ctx = { currentPlayer: '0' } as Ctx; + + // when + render( + , + ); + + // then + const waitingForPlayerText = await findBrokenByText( + 'Waiting for P1 to play a card.', + ); + expect(waitingForPlayerText).toBeInTheDocument(); + }); + + it('renders waiting for players to play threats or pass text in threat stage', async () => { + // given + const ctx = { currentPlayer: '0', numPlayers: 3 } as Ctx; + + // when + render( + , + ); + + // then + const text = await findBrokenByText( + 'You dealt E2, waiting for You, P2 and P3 to add threats or pass.', + ); + expect(text).toBeInTheDocument(); + }); + + it('renders last won in play stage, if a round has been won already', async () => { + // given + const GWithWInnerOfPreviousRound = { ...G, round: 2, lastWinner: 1 }; + const ctx = { currentPlayer: '0' } as Ctx; + + // when + render( + , + ); + + // then + const waitingForPlayerText = await findBrokenByText( + 'Last round won by P2. Waiting for You to play a card.', + ); + expect(waitingForPlayerText).toBeInTheDocument(); + }); +}); + +function findBrokenByText(text: string) { + return screen.findByText((_, element) => { + const hasText = (element: Element | null) => element?.textContent === text; + const nodeHasText = hasText(element); + const childrenDontHaveText = Array.from(element?.children ?? []).every( + (child) => !hasText(child), + ); + + return nodeHasText && childrenDontHaveText; + }); +} diff --git a/src/client/components/status/status.tsx b/src/client/components/status/status.tsx new file mode 100644 index 0000000..66d7c16 --- /dev/null +++ b/src/client/components/status/status.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import type { FC } from 'react'; +import type { Ctx, PlayerID } from 'boardgame.io'; +import { getCardDisplayName } from '../../../utils/cardDefinitions'; +import { + getPlayers, + grammarJoin, + resolvePlayerName, + resolvePlayerNames, +} from '../../../utils/utils'; +import type { GameState } from '../../../game/gameState'; +import './status.css'; + +type StatusProps = { + playerID: PlayerID | null; + G: GameState; + ctx: Ctx; + names: string[]; + dealtCard: string; + isInThreatStage: boolean; +}; + +const Status: FC = ({ + playerID, + ctx, + names, + isInThreatStage = false, + dealtCard, + G, +}) => { + const isSpectator = playerID !== null; + const isFirstPlayerInThreatStage = ctx.activePlayers?.[0] === 'threats'; + if (!(isInThreatStage || (isSpectator && isFirstPlayerInThreatStage))) { + const currentPlayerName = resolvePlayerName( + ctx.currentPlayer, + names, + playerID, + ); + + const prefix = + dealtCard === '' && G.round > 1 ? ( + <> + Last round won by{' '} + + {resolvePlayerName(G.lastWinner.toString(), names, playerID)} + + .{' '} + + ) : undefined; + + return ( + + {prefix}Waiting for {currentPlayerName} to play a card. + + ); + } else { + const all = new Set(getPlayers(ctx.numPlayers)); + const passed = new Set(G.passed); + const difference = new Set([...all].filter((x) => !passed.has(x))); + const players = resolvePlayerNames(Array.from(difference), names, playerID); + const playerWhoDealt = resolvePlayerName(G.dealtBy, names, playerID); + + return ( + + {playerWhoDealt} dealt{' '} + {getCardDisplayName(G.gameMode, dealtCard)}, waiting + for {grammarJoin(players)} to add threats or pass. + + ); + } +}; + +export default Status; diff --git a/src/client/components/threatbar/threatbar.jsx b/src/client/components/threatbar/threatbar.jsx deleted file mode 100644 index 3953c3a..0000000 --- a/src/client/components/threatbar/threatbar.jsx +++ /dev/null @@ -1,299 +0,0 @@ -import { - faBolt, - faEdit, - faPlus, - faTrash, -} from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import PropTypes from 'prop-types'; -import React from 'react'; -import nl2br from 'react-nl2br'; -import { - Button, - Card, - CardBody, - CardFooter, - CardHeader, - CardText, - Col, - Collapse, - ListGroup, - ListGroupItem, - Row, -} from 'reactstrap'; -import confirm from 'reactstrap-confirm'; -import { getSuitDisplayName } from '../../../utils/cardDefinitions'; -import { getComponentName } from '../../../utils/utils'; -import ThreatModal from '../threatmodal/threatmodal'; -import './threatbar.css'; - -class Threatbar extends React.Component { - static get propTypes() { - return { - playerID: PropTypes.any, - model: PropTypes.any, - G: PropTypes.any.isRequired, - ctx: PropTypes.any.isRequired, - moves: PropTypes.any.isRequired, - active: PropTypes.bool.isRequired, - names: PropTypes.any.isRequired, - isInThreatStage: PropTypes.bool, - }; - } - - static get defaultProps() { - return { - isInThreatStage: false, - }; - } - - getSelectedComponent() { - if (this.props.G.selectedComponent === '' || this.props.model === null) { - return null; - } - - let diagram = - this.props.model.detail.diagrams[this.props.G.selectedDiagram] - .diagramJson; - for (let i = 0; i < diagram.cells.length; i++) { - let cell = diagram.cells[i]; - if (cell.id === this.props.G.selectedComponent) { - return cell; - } - } - - return null; - } - - getThreatsForSelectedComponent() { - let threats = []; - if (this.props.G.selectedComponent === '' || this.props.model === null) { - return threats; - } - - let diagram = - this.props.model.detail.diagrams[this.props.G.selectedDiagram] - .diagramJson; - for (let i = 0; i < diagram.cells.length; i++) { - let cell = diagram.cells[i]; - if (this.props.G.selectedComponent !== '') { - if (cell.id === this.props.G.selectedComponent) { - if (Array.isArray(cell.threats)) { - // fix threat ids - for (let j = 0; j < cell.threats.length; j++) { - if (!('id' in cell.threats[j])) { - cell.threats[j].id = j + ''; - } - } - return cell.threats; - } - } - } else { - /* - if (Array.isArray(cell.threats)) { - threats = threats.concat(cell.threats); - } - */ - } - } - return threats; - } - - getIdentifiedThreatsForSelectedComponent() { - let threats = []; - if (this.props.G.selectedDiagram in this.props.G.identifiedThreats) { - if ( - this.props.G.selectedComponent in - this.props.G.identifiedThreats[this.props.G.selectedDiagram] - ) { - for (let k in this.props.G.identifiedThreats[ - this.props.G.selectedDiagram - ][this.props.G.selectedComponent]) { - let t = - this.props.G.identifiedThreats[this.props.G.selectedDiagram][ - this.props.G.selectedComponent - ][k]; - threats.push(t); - } - } - } - - return threats; - } - - render() { - const threats = [...this.getThreatsForSelectedComponent()].reverse(); - const identifiedThreats = [ - ...this.getIdentifiedThreatsForSelectedComponent(), - ].reverse(); - const component = this.getSelectedComponent(); - const componentName = getComponentName(component); - - return ( - - ); - } -} - -export default Threatbar; diff --git a/src/client/components/threatbar/threatbar.test.js b/src/client/components/threatbar/threatbar.test.js deleted file mode 100644 index e60ef91..0000000 --- a/src/client/components/threatbar/threatbar.test.js +++ /dev/null @@ -1,110 +0,0 @@ -import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; -import { GameMode } from '../../../utils/GameMode'; -import React from 'react'; -import Threatbar from './threatbar'; - -describe('', () => { - const G = { - gameMode: GameMode.EOP, - threat: { - modal: false, - }, - selectedDiagram: 'diagram1', - selectedComponent: 'component1', - identifiedThreats: { - diagram1: { - component1: { - threat1: { - title: 'Identified Threat 1', - }, - threat2: { - title: 'Identified Threat 2', - }, - }, - }, - }, - }; - - it('shows identified threats in reverse order', () => { - const model = { - detail: { - diagrams: { - diagram1: { - diagramJson: { - cells: [ - { - id: 'component1', - type: 'type', - attrs: { - text: { - text: 'text', - }, - }, - threats: [ - { - title: 'Existing Threat 1', - }, - { - title: 'Existing Threat 2', - }, - ], - }, - ], - }, - }, - }, - }, - }; - - render( - , - ); - - const threats = screen.getAllByText(/^Identified Threat \d+$/); - expect(threats).toHaveLength(2); - expect(threats[0]).toHaveTextContent('Identified Threat 2'); - expect(threats[1]).toHaveTextContent('Identified Threat 1'); - }); - - it('shows existing threats in reverse order', () => { - const model = { - detail: { - diagrams: { - diagram1: { - diagramJson: { - cells: [ - { - id: 'component1', - type: 'type', - attrs: { - text: { - text: 'text', - }, - }, - threats: [ - { - title: 'Existing Threat 1', - }, - { - title: 'Existing Threat 2', - }, - ], - }, - ], - }, - }, - }, - }, - }; - - render( - , - ); - - const threats = screen.getAllByText(/^Existing Threat \d+$/); - expect(threats).toHaveLength(2); - expect(threats[0]).toHaveTextContent('Existing Threat 2'); - expect(threats[1]).toHaveTextContent('Existing Threat 1'); - }); -}); diff --git a/src/client/components/threatbar/threatbar.test.tsx b/src/client/components/threatbar/threatbar.test.tsx new file mode 100644 index 0000000..e550f90 --- /dev/null +++ b/src/client/components/threatbar/threatbar.test.tsx @@ -0,0 +1,194 @@ +import { render, screen } from '@testing-library/react'; +import { GameMode } from '../../../utils/GameMode'; +import React from 'react'; +import Threatbar from './threatbar'; +import type { GameState } from '../../../game/gameState'; +import { ModelType } from '../../../utils/constants'; +import type { ThreatDragonModel } from '../../../types/ThreatDragonModel'; + +describe('', () => { + const selectedDiagram = 0; + const G: GameState = { + gameMode: GameMode.EOP, + threat: { + modal: false, + new: false, + }, + selectedDiagram, + selectedComponent: 'component1', + identifiedThreats: [ + { + component1: { + threat1: { + title: 'Identified Threat 1', + modal: false, + new: false, + }, + threat2: { + title: 'Identified Threat 2', + modal: false, + new: false, + }, + }, + }, + ], + dealt: [], + passed: [], + suit: undefined, + dealtBy: '', + players: [], + round: 0, + numCardsPlayed: 0, + scores: [], + lastWinner: 0, + maxRounds: 0, + selectedThreat: '', + startingCard: '', + turnDuration: 0, + modelType: ModelType.IMAGE, + }; + + it('shows identified threats in reverse order', () => { + const model: ThreatDragonModel = { + summary: { + title: 'title', + }, + detail: { + diagrams: [ + { + id: 0, + diagramJson: { + cells: [ + { + size: { width: 0, height: 0 }, + position: { x: 0, y: 0 }, + angle: 0, + z: 0, + id: 'component1', + type: 'tm.Actor', + attrs: { + text: { + text: 'text', + }, + }, + hasOpenThreats: true, + threats: [ + { + title: 'Existing Threat 1', + status: 'Open', + description: '', + mitigation: '', + severity: '', + type: '', + }, + { + title: 'Existing Threat 2', + status: 'Open', + description: '', + mitigation: '', + severity: '', + type: '', + }, + ], + }, + ], + }, + diagramType: 'STRIDE', + size: { width: 0, height: 0 }, + thumbnail: '', + title: '', + }, + ], + }, + }; + + render( + , + ); + + const threats = screen.getAllByText(/^Identified Threat \d+$/); + expect(threats).toHaveLength(2); + expect(threats[0]).toHaveTextContent('Identified Threat 2'); + expect(threats[1]).toHaveTextContent('Identified Threat 1'); + }); + + it('shows existing threats in reverse order', () => { + const model: ThreatDragonModel = { + summary: { + title: 'title', + }, + detail: { + diagrams: [ + { + id: 0, + diagramJson: { + cells: [ + { + size: { width: 0, height: 0 }, + position: { x: 0, y: 0 }, + angle: 0, + z: 0, + id: 'component1', + type: 'tm.Actor', + attrs: { + text: { + text: 'text', + }, + }, + hasOpenThreats: true, + threats: [ + { + title: 'Existing Threat 1', + status: 'Open', + description: '', + mitigation: '', + severity: '', + type: '', + }, + { + title: 'Existing Threat 2', + status: 'Open', + description: '', + mitigation: '', + severity: '', + type: '', + }, + ], + }, + ], + }, + diagramType: 'STRIDE', + size: { width: 0, height: 0 }, + thumbnail: '', + title: '', + }, + ], + }, + }; + + render( + , + ); + + const threats = screen.getAllByText(/^Existing Threat \d+$/); + expect(threats).toHaveLength(2); + expect(threats[0]).toHaveTextContent('Existing Threat 2'); + expect(threats[1]).toHaveTextContent('Existing Threat 1'); + }); +}); diff --git a/src/client/components/threatbar/threatbar.tsx b/src/client/components/threatbar/threatbar.tsx new file mode 100644 index 0000000..946ce1f --- /dev/null +++ b/src/client/components/threatbar/threatbar.tsx @@ -0,0 +1,230 @@ +import { + faBolt, + faEdit, + faPlus, + faTrash, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import React from 'react'; +import type { FC } from 'react'; +import nl2br from 'react-nl2br'; +import { + Button, + Card, + CardBody, + CardFooter, + CardHeader, + CardText, + Col, + Collapse, + Row, +} from 'reactstrap'; +import confirm from 'reactstrap-confirm'; +import { getSuitDisplayName } from '../../../utils/cardDefinitions'; +import { getComponentName } from '../../../utils/utils'; +import ThreatModal from '../threatmodal/threatmodal'; +import './threatbar.css'; +import type { GameState } from '../../../game/gameState'; +import type { BoardProps } from 'boardgame.io/react'; +import type { + ThreatDragonModel, + ThreatDragonThreat, +} from '../../../types/ThreatDragonModel'; + +type ThreatbarProps = { + model?: ThreatDragonModel; + active: boolean; + names: string[]; + isInThreatStage: boolean; +} & Pick, 'G' | 'moves' | 'playerID'>; + +const Threatbar: FC = ({ + playerID, + model, + G, + moves, + active, + names, + isInThreatStage, +}) => { + const getSelectedComponent = () => { + if (G.selectedComponent === '' || model === undefined) { + return undefined; + } + + const diagram = model.detail.diagrams[G.selectedDiagram].diagramJson; + return diagram.cells?.find((cell) => cell.id === G.selectedComponent); + }; + + const getThreatsForSelectedComponent = (): ThreatDragonThreat[] => { + const component = getSelectedComponent(); + + return ( + component?.threats?.map((threat, index) => ({ + ...threat, + // add ids if they are missing + id: threat.id ?? `${index}`, + })) ?? [] + ); + }; + + const getIdentifiedThreatsForSelectedComponent = () => + Object.values( + G.identifiedThreats[G.selectedDiagram]?.[G.selectedComponent] ?? {}, + ); + + const threats = getThreatsForSelectedComponent().reverse(); + const identifiedThreats = + getIdentifiedThreatsForSelectedComponent().reverse(); + const component = getSelectedComponent(); + const componentName = getComponentName(component); + + return ( + + ); +}; + +export default Threatbar; diff --git a/src/client/components/threatmodal/threatmodal.jsx b/src/client/components/threatmodal/threatmodal.jsx deleted file mode 100644 index 764fa63..0000000 --- a/src/client/components/threatmodal/threatmodal.jsx +++ /dev/null @@ -1,264 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { - Button, - Form, - FormGroup, - Input, - Label, - Modal, - ModalBody, - ModalFooter, - ModalHeader, -} from 'reactstrap'; -import { getSuitDisplayName, getSuits } from '../../../utils/cardDefinitions'; -import { ModelType } from '../../../utils/constants'; - -class ThreatModal extends React.Component { - static get propTypes() { - return { - playerID: PropTypes.any, - G: PropTypes.any.isRequired, - ctx: PropTypes.any.isRequired, - moves: PropTypes.any.isRequired, - names: PropTypes.any.isRequired, - isOpen: PropTypes.bool.isRequired, - }; - } - - constructor(props) { - super(props); - this.state = { - title: '', - description: '', - mitigation: '', - showMitigation: false, - }; - } - - componentDidUpdate(prevProps) { - if ( - prevProps.G.threat.title !== this.props.G.threat.title || - prevProps.G.threat.description !== this.props.G.threat.description || - prevProps.G.threat.mitigation !== this.props.G.threat.mitigation - ) { - this.setState({ - title: this.props.G.threat.title, - description: this.props.G.threat.description, - mitigation: this.props.G.threat.mitigation, - }); - } - } - - saveThreat() { - for (let field in ['title', 'description', 'mitigation']) { - if (this.props.G.threat[field] !== this.state[field]) { - this.props.moves.updateThreat(field, this.state[field]); - } - } - - if (!this.props.G.threat.mitigation) { - this.props.moves.updateThreat('mitigation', 'No mitigation provided.'); - } - - if (!this.props.G.threat.description) { - this.props.moves.updateThreat('description', 'No description provided.'); - } - } - - addOrUpdate() { - // update the values from the state - this.saveThreat(); - this.props.moves.addOrUpdateThreat(); - this.toggleMitigationField(false); - } - - toggleMitigationField(isShown) { - this.setState({ - showMitigation: isShown, - }); - } - - get isInvalid() { - return _.isEmpty(this.state.title); - } - - get isOwner() { - return this.props.G.threat.owner === this.props.playerID; - } - - get isPrivacyEnhancedMode() { - return this.props.G.modelType === ModelType.PRIVACY_ENHANCED; - } - - threatDetailModalBody() { - return ( - - - - - this.props.moves.updateThreat('title', e.target.value) - } - onChange={(e) => this.setState({ title: e.target.value })} - /> - - - - - this.props.moves.updateThreat('type', e.target.value) - } - > - {getSuits(this.props.G.gameMode).map((suit) => ( - - ))} - - - - - - this.props.moves.updateThreat('severity', e.target.value) - } - > - - - - - - - - - this.props.moves.updateThreat('description', e.target.value) - } - onChange={(e) => this.setState({ description: e.target.value })} - /> - - - - - ); - } - - threatRestrictedDetailModalBody() { - return ( - - - - - this.props.moves.updateThreat('title', e.target.value) - } - onChange={(e) => this.setState({ title: e.target.value })} - /> - - - ); - } - - render() { - return ( - -
- this.props.moves.toggleModal() : undefined - } - style={{ width: '100%' }} - > - {this.props.G.threat.new ? 'Add' : 'Update'} Threat —{' '} - - being {this.props.G.threat.new ? 'added' : 'updated'} by{' '} - {this.props.names[this.props.G.threat.owner]} - - - - {this.isPrivacyEnhancedMode - ? this.threatRestrictedDetailModalBody() - : this.threatDetailModalBody()} - - {this.isOwner && ( - - - - - )} -
-
- ); - } -} - -export default ThreatModal; diff --git a/src/client/components/threatmodal/threatmodal.test.js b/src/client/components/threatmodal/threatmodal.test.tsx similarity index 70% rename from src/client/components/threatmodal/threatmodal.test.js rename to src/client/components/threatmodal/threatmodal.test.tsx index c38f3ba..bdcff28 100644 --- a/src/client/components/threatmodal/threatmodal.test.js +++ b/src/client/components/threatmodal/threatmodal.test.tsx @@ -1,102 +1,83 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import ThreatModal from './threatmodal'; import { DEFAULT_GAME_MODE } from '../../../utils/GameMode'; -import userEvent from '@testing-library/user-event'; +import type { GameState } from '../../../game/gameState'; +import { ModelType } from '../../../utils/constants'; + +const baseG: GameState = { + dealt: ['T1'], + scores: [0, 0, 0], + selectedComponent: '', + selectedDiagram: 0, + identifiedThreats: [], + threat: { + modal: true, + new: true, + owner: '0', + }, + gameMode: DEFAULT_GAME_MODE, + passed: [], + suit: undefined, + dealtBy: '', + players: [], + round: 0, + numCardsPlayed: 0, + lastWinner: 0, + maxRounds: 0, + selectedThreat: '', + startingCard: '', + turnDuration: 0, + modelType: ModelType.IMAGE, +}; + +const moves = {}; it('renders without crashing for a new threat', () => { - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - selectedComponent: '', - selectedDiagram: '0', - identifiedThreats: {}, - threat: { - modal: true, - owner: '0', - }, - gameMode: DEFAULT_GAME_MODE, - }; - const ctx = {}; const moves = {}; render( , ); }); it('renders without crashing for an existing threat', () => { - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - selectedComponent: '', - selectedDiagram: '0', - identifiedThreats: {}, - threat: { - modal: true, - new: false, - owner: '0', - }, - gameMode: DEFAULT_GAME_MODE, - }; - const ctx = {}; - const moves = {}; + const G: GameState = { ...baseG, threat: { ...baseG.threat, new: false } }; render( , ); }); describe('for the owner of the threat', () => { const playerID = '0'; - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - selectedComponent: '', - selectedDiagram: '0', - identifiedThreats: {}, - threat: { - modal: true, - owner: playerID, - }, - gameMode: DEFAULT_GAME_MODE, + const G: GameState = { + ...baseG, + threat: { ...baseG.threat, owner: playerID }, }; - const ctx = {}; - const moves = {}; it('renders a close button', () => { // when render( , ); @@ -108,14 +89,11 @@ describe('for the owner of the threat', () => { // when render( , ); @@ -129,14 +107,11 @@ describe('for the owner of the threat', () => { const toggleModal = jest.fn(); render( , ); @@ -154,14 +129,11 @@ describe('for the owner of the threat', () => { const toggleModal = jest.fn(); render( , ); @@ -181,14 +153,11 @@ describe('for the owner of the threat', () => { render( , ); @@ -213,14 +182,11 @@ describe('for the owner of the threat', () => { render( , ); @@ -245,58 +211,45 @@ describe('for the owner of the threat', () => { describe('for players other than the owner of the threat', () => { const playerID = '0'; const ownerID = '1'; - const G = { - dealt: ['T1'], - order: [0, 1, 2], - scores: [0, 0, 0], - selectedComponent: '', - selectedDiagram: '0', - identifiedThreats: {}, + + const G: GameState = { + ...baseG, threat: { - modal: true, + ...baseG.threat, owner: ownerID, }, - gameMode: DEFAULT_GAME_MODE, }; - const ctx = {}; - const moves = {}; it('does not render a close button', () => { // when render( , ); // then - expect(screen.queryByText('×')).toBeNull(); + expect(screen.queryByText('×')).not.toBeInTheDocument(); }); it('does not render save and cancel buttons', () => { // when render( , ); // then - expect(screen.queryByText('Save')).toBeNull(); - expect(screen.queryByText('Cancel')).toBeNull(); + expect(screen.queryByText('Save')).not.toBeInTheDocument(); + expect(screen.queryByText('Cancel')).not.toBeInTheDocument(); }); }); diff --git a/src/client/components/threatmodal/threatmodal.tsx b/src/client/components/threatmodal/threatmodal.tsx new file mode 100644 index 0000000..84fb4c3 --- /dev/null +++ b/src/client/components/threatmodal/threatmodal.tsx @@ -0,0 +1,235 @@ +import type { BoardProps } from 'boardgame.io/react'; +import _ from 'lodash'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { + Button, + Form, + FormGroup, + Input, + Label, + Modal, + ModalBody, + ModalFooter, + ModalHeader, +} from 'reactstrap'; +import { getSuitDisplayName, getSuits } from '../../../utils/cardDefinitions'; +import { ModelType } from '../../../utils/constants'; +import type { GameState } from '../../../game/gameState'; +import { resolvePlayerName } from '../../../utils/utils'; + +type ThreatModalProps = { + names: string[]; + isOpen: boolean; +} & Pick, 'G' | 'moves' | 'playerID'>; + +const ThreatModal: FC = ({ + playerID, + G, + moves, + names, + isOpen, +}) => { + const [title, setTitle] = useState(G.threat.title); + const [description, setDescription] = useState(G.threat.description); + const [mitigation, setMitigation] = useState(G.threat.mitigation); + const [showMitigation, setShowMitigation] = useState(false); + + useEffect(() => { + setTitle(G.threat.title); + setDescription(G.threat.description); + setMitigation(G.threat.mitigation); + }, [G.threat.title, G.threat.description, G.threat.mitigation]); + + const isPrivacyEnhancedMode = G.modelType === ModelType.PRIVACY_ENHANCED; + const isOwner = G.threat.owner === playerID; + const isInvalid = _.isEmpty(title); + + const saveThreat = () => { + if (G.threat.title !== title) { + moves.updateThreat('title', title); + } + + const descriptionToUse = description ?? 'No description provided.'; + if (G.threat.description !== descriptionToUse) { + moves.updateThreat('description', descriptionToUse); + } + + if (G.threat.mitigation !== mitigation) { + moves.updateThreat('mitigation', mitigation); + } + + if (!G.threat.mitigation) { + moves.updateThreat('mitigation', 'No mitigation provided.'); + } + }; + + const addOrUpdate = () => { + // update the values from the state + saveThreat(); + moves.addOrUpdateThreat(); + setShowMitigation(false); + }; + + const threatDetailModalBody = useCallback( + () => ( + + + + moves.updateThreat('title', e.target.value)} + onChange={(e) => setTitle(e.target.value)} + /> + + + + moves.updateThreat('type', e.target.value)} + > + {getSuits(G.gameMode).map((suit) => ( + + ))} + + + + + moves.updateThreat('severity', e.target.value)} + > + + + + + + + + moves.updateThreat('description', e.target.value)} + onChange={(e) => setDescription(e.target.value)} + /> + + + + + ), + [ + isOwner, + title, + moves.updateThreat, + G.threat.type, + G.gameMode, + G.threat.severity, + description, + showMitigation, + mitigation, + ], // TODO: add eslint rule to check for exhaustive dependencies + ); + + const threatRestrictedDetailModalBody = useCallback( + () => ( + + + + moves.updateThreat('title', e.target.value)} + onChange={(e) => setTitle(e.target.value)} + /> + + + ), + [isOwner, title, moves.updateThreat], + ); + + return ( + +
+ moves.toggleModal() : undefined} + style={{ width: '100%' }} + > + {G.threat.new ? 'Add' : 'Update'} Threat —{' '} + + being {G.threat.new ? 'added' : 'updated'} by{' '} + {resolvePlayerName(G.threat.owner ?? '', names, playerID)} + + + + {isPrivacyEnhancedMode + ? threatRestrictedDetailModalBody() + : threatDetailModalBody()} + + {isOwner && ( + + + + + )} +
+
+ ); +}; + +export default ThreatModal; diff --git a/src/client/components/timer/timer.test.tsx b/src/client/components/timer/timer.test.tsx index 0bf1201..e99b9cd 100644 --- a/src/client/components/timer/timer.test.tsx +++ b/src/client/components/timer/timer.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import React from 'react'; import Timer from './timer'; import { render, screen } from '@testing-library/react'; diff --git a/src/client/components/timer/timer.tsx b/src/client/components/timer/timer.tsx index 55a27a3..058be03 100644 --- a/src/client/components/timer/timer.tsx +++ b/src/client/components/timer/timer.tsx @@ -53,7 +53,7 @@ const Timer: FC = ({ active = true, duration, targetTime }) => { colorsTime={[0.625, 0.25, 0.125]} onComplete={() => ({ shouldRepeat: false, - newInitialRemainingTime: 0 + newInitialRemainingTime: 0, })} > {renderTime} diff --git a/src/client/pages/__tests__/create.test.tsx b/src/client/pages/__tests__/create.test.tsx index 69984cc..cb779fb 100644 --- a/src/client/pages/__tests__/create.test.tsx +++ b/src/client/pages/__tests__/create.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import React from 'react'; import { render, screen } from '@testing-library/react'; import Create from '../create'; @@ -6,71 +5,90 @@ import { BrowserRouter as Router } from 'react-router-dom'; describe('Create', () => { it('renders without crashing', async () => { - render(); + render( + + + , + ); await screen.getAllByRole('button', { - name: 'Proceed' + name: 'Proceed', }); }); it('should render imprint link if env var is defined', async () => { // given - process.env.REACT_APP_EOP_IMPRINT = 'https://example.tld/imprint/' + process.env.REACT_APP_EOP_IMPRINT = 'https://example.tld/imprint/'; // when - render(); + render( + + + , + ); // when // then const links = await screen.queryAllByRole('link', { - name: `Imprint` + name: `Imprint`, }); expect(links.length).toBe(1); }); it('should not render imprint link if env var is not defined', async () => { // given - process.env.REACT_APP_EOP_IMPRINT = ""; + process.env.REACT_APP_EOP_IMPRINT = ''; // when - render(); + render( + + + , + ); // then const links = await screen.queryAllByRole('link', { - name: `Imprint` + name: `Imprint`, }); expect(links.length).toBe(0); }); it('should render privacy link if env var is defined', async () => { // given - process.env.REACT_APP_EOP_PRIVACY = 'https://example.tld/privacy/' + process.env.REACT_APP_EOP_PRIVACY = 'https://example.tld/privacy/'; // when - render(); + render( + + + , + ); // when // then const links = await screen.queryAllByRole('link', { - name: `Privacy` + name: `Privacy`, }); expect(links.length).toBe(1); }); it('should not render privacy link if env var is not defined', async () => { // given - process.env.REACT_APP_EOP_PRIVACY = ""; + process.env.REACT_APP_EOP_PRIVACY = ''; // when - render(); + render( + + + , + ); // then const links = await screen.queryAllByRole('link', { - name: `Privacy` + name: `Privacy`, }); expect(links.length).toBe(0); }); - }); diff --git a/src/game/__tests__/eop.test.js b/src/game/__tests__/eop.test.ts similarity index 51% rename from src/game/__tests__/eop.test.js rename to src/game/__tests__/eop.test.ts index ae5420b..5e3df24 100644 --- a/src/game/__tests__/eop.test.js +++ b/src/game/__tests__/eop.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_START_SUIT } from '../../utils/constants'; import { DEFAULT_GAME_MODE } from '../../utils/GameMode'; import { ElevationOfPrivilege } from '../eop'; +// TODO: refactor so that tests do not depend on each other describe('game', () => { const spec = { game: ElevationOfPrivilege, @@ -12,102 +13,105 @@ describe('game', () => { multiplayer: Local(), }; const players = { - 0: Client({ ...spec, playerID: '0' }), - 1: Client({ ...spec, playerID: '1' }), - 2: Client({ ...spec, playerID: '2' }), + '0': Client({ ...spec, playerID: '0' }), + '1': Client({ ...spec, playerID: '1' }), + '2': Client({ ...spec, playerID: '2' }), }; - let createdThreat = ''; + let createdThreat: string | undefined; const STARTING_CARD = getStartingCard(DEFAULT_GAME_MODE, DEFAULT_START_SUIT); - Object.keys(players).forEach((k) => { - players[k].start(); - }); + Object.values(players).forEach((p) => p.start()); - const startingPlayer = players['0'].getState().ctx.currentPlayer; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const startingPlayer = players['0'].getState()!.ctx.currentPlayer!; it("shouldn't start in a stage/phase", () => { - expect(players['0'].getState().ctx.activePlayers).toBeFalsy(); - expect(players['0'].getState().ctx.phase).toBeFalsy(); + expect(players['0'].getState()?.ctx.activePlayers).toBe(null); + expect(players['0'].getState()?.ctx.phase).toBe(null); }); it('should start with scores set to zero', () => { - expect(players['0'].getState().G.scores).toStrictEqual([0, 0, 0]); + expect(players['0'].getState()?.G.scores).toStrictEqual([0, 0, 0]); }); it('have correct order in ctx', () => { const state = players['0'].getState(); - expect(state.ctx.playOrder).toStrictEqual(['0', '1', '2']); + expect(state?.ctx.playOrder).toStrictEqual(['0', '1', '2']); }); it('should move to the threats stage when the first player makes a move', () => { - const starting = players['0'].getState().ctx.currentPlayer; + const starting = players['0'].getState()?.ctx + .currentPlayer as keyof typeof players; players[starting].moves.draw(STARTING_CARD); const state = players['0'].getState(); - expect(state.G.dealt).toContain(STARTING_CARD); - expect(state.G.dealtBy).toBe(starting); - expect(state.G.suit).toBe(STARTING_CARD.slice(0, 1)); - expect(players['0'].getState().ctx.activePlayers).toStrictEqual({ - 0: 'threats', - 1: 'threats', - 2: 'threats', + expect(state?.G.dealt).toContain(STARTING_CARD); + expect(state?.G.dealtBy).toBe(starting); + expect(state?.G.suit).toBe(STARTING_CARD.slice(0, 1)); + expect(players['0'].getState()?.ctx.activePlayers).toStrictEqual({ + '0': 'threats', + '1': 'threats', + '2': 'threats', }); }); it('the played card should not be present in the deck', () => { - const lastPlayer = players['0'].getState().G.dealtBy; - const cards = players[lastPlayer].getState().G.players[lastPlayer]; - expect(cards.includes(STARTING_CARD)).toBeFalsy(); + const lastPlayer = players['0'].getState()?.G + .dealtBy as keyof typeof players; + const cards = players[lastPlayer].getState()?.G.players[lastPlayer]; + expect(cards?.includes(STARTING_CARD)).toBe(false); }); it('player should not be able to draw again', () => { - const lastPlayer = players['0'].getState().G.dealtBy; - const cards = players[lastPlayer].getState().G.players[lastPlayer]; + const lastPlayer = players['0'].getState()?.G + .dealtBy as keyof typeof players; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cards = players[lastPlayer].getState()!.G.players[lastPlayer]!; const card = cards[Math.floor(Math.random() * cards.length)]; players[lastPlayer].moves.draw(card); const state = players['0'].getState(); - expect(state.G.dealt.includes(card)).toBeFalsy(); + expect(state?.G.dealt.includes(card)).toBe(false); }); it('anyone should be able to select diagrams in a threats stage', () => { - Object.keys(players).forEach((k) => { - players[k].moves.selectDiagram(k); + Object.values(players).forEach((p, i) => { + p.moves.selectDiagram(i); const state = players['0'].getState(); - expect(state.G.selectedDiagram).toBe(k); + expect(state?.G.selectedDiagram).toBe(i); }); }); it('anyone should be able to select components in a threats stage', () => { - Object.keys(players).forEach((k) => { - players[k].moves.selectComponent(k); + Object.values(players).forEach((p, i) => { + p.moves.selectComponent(`${i}`); const state = players['0'].getState(); - expect(state.G.selectedComponent).toBe(k); + expect(state?.G.selectedComponent).toBe(`${i}`); }); }); it('anyone should be able to select threats in a threats stage', () => { - Object.keys(players).forEach((k) => { - players[k].moves.selectThreat(k); + Object.values(players).forEach((p, i) => { + p.moves.selectThreat(`${i}`); const state = players['0'].getState(); - expect(state.G.selectedThreat).toBe(k); + expect(state?.G.selectedThreat).toBe(`${i}`); }); }); it('anyone should be able to toggle the threat add modal in a threats stage', () => { - Object.keys(players).forEach((k) => { - players[k].moves.toggleModal(); - let state = players['0'].getState(); - expect(state.G.threat.modal).toBeTruthy(); - expect(state.G.threat.owner).toBe(k); + Object.values(players).forEach((p) => { + p.moves.toggleModal(); + const state = players['0'].getState(); + expect(state?.G.threat.modal).toBe(true); + expect(state?.G.threat.owner).toBe(p.playerID); // only the owner should be able to toggle the modal again - players[k].moves.toggleModal(); - state = players['0'].getState(); - expect(state.G.threat.modal).toBeFalsy(); + p.moves.toggleModal(); + const state2 = players['0'].getState(); + expect(state2?.G.threat.modal).toBe(false); }); }); @@ -115,43 +119,45 @@ describe('game', () => { players['0'].moves.pass(); players['0'].moves.pass(); players['0'].moves.toggleModal(); - let state = players['0'].getState(); - expect(state.G.threat.modal).toBeFalsy(); + const state = players['0'].getState(); + expect(state?.G.threat.modal).toBe(false); }); it('the players who have passed should not be able to select a diagram', () => { players['0'].moves.selectDiagram('foo'); - let state = players['0'].getState(); - expect(state.G.selectedDiagram).not.toBe('foo'); + const state = players['0'].getState(); + expect(state?.G.selectedDiagram).not.toBe('foo'); }); it('the players who have passed should not be able to select a component', () => { players['0'].moves.selectComponent('foo'); - let state = players['0'].getState(); - expect(state.G.selectedComponent).not.toBe('foo'); + const state = players['0'].getState(); + expect(state?.G.selectedComponent).not.toBe('foo'); }); it('the players who have passed should not be able to select a threat', () => { players['0'].moves.selectThreat('foo'); - let state = players['0'].getState(); - expect(state.G.selectedThreat).not.toBe('foo'); + const state = players['0'].getState(); + expect(state?.G.selectedThreat).not.toBe('foo'); }); it('the players who have not passed should be able to add a threat', () => { players['1'].moves.toggleModal(); - let state = players['0'].getState(); - let diagram = state.G.selectedDiagram; - let component = state.G.selectedComponent; - createdThreat = state.G.threat.id; + const state = players['0'].getState(); + const diagram = state?.G.selectedDiagram; + const component = state?.G.selectedComponent; + createdThreat = state?.G.threat.id; players['1'].moves.updateThreat('title', 'foo'); players['1'].moves.updateThreat('description', 'bar'); players['1'].moves.updateThreat('mitigation', 'baz'); players['1'].moves.addOrUpdateThreat(); - state = players['0'].getState(); + const state2 = players['0'].getState(); - const t = state.G.identifiedThreats[diagram][component][createdThreat]; + const t = + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + state2!.G.identifiedThreats[diagram!]![component!]![createdThreat!]; expect(t.id).toBe(createdThreat); expect(t.owner).toBe('1'); expect(t.title).toBe('foo'); @@ -166,8 +172,8 @@ describe('game', () => { description: 'bar', mitigation: 'baz', }); - let state = players['0'].getState(); - expect(state.G.threat.new).toBeFalsy(); + const state = players['0'].getState(); + expect(state?.G.threat.new).toBe(false); players['1'].moves.toggleModal(); }); @@ -177,21 +183,23 @@ describe('game', () => { id: createdThreat, }); - let state = players['0'].getState(); - let diagram = state.G.selectedDiagram; - let component = state.G.selectedComponent; - expect(state.G.identifiedThreats[diagram][component]).toStrictEqual({}); + const state = players['0'].getState(); + const diagram = state?.G.selectedDiagram; + const component = state?.G.selectedComponent; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(state!.G.identifiedThreats[diagram!]![component!]).toStrictEqual({}); }); it('should move on when all players have passed', () => { players['1'].moves.pass(); players['2'].moves.pass(); - expect(players['0'].getState().ctx.activePlayers).toBeFalsy(); + expect(players['0'].getState()?.ctx.activePlayers).toBe(null); }); it('should respect the play order', () => { - const state = players['0'].getState(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const state = players['0'].getState()!; const nextPlayer = (parseInt(startingPlayer) + 1) % state.ctx.numPlayers; expect(state.ctx.currentPlayer).toBe(nextPlayer.toString()); }); diff --git a/src/game/gameState.ts b/src/game/gameState.ts index 8e73ed0..b940083 100644 --- a/src/game/gameState.ts +++ b/src/game/gameState.ts @@ -5,7 +5,7 @@ import type { GameMode } from '../utils/GameMode'; import type { Threat } from './threat'; export interface GameState { - dealt: string[]; + dealt: (string | null)[]; passed: PlayerID[]; suit: Suit | undefined; dealtBy: PlayerID; @@ -19,7 +19,7 @@ export interface GameState { selectedComponent: string; selectedThreat: string; threat: Threat; - identifiedThreats: Record>>; + identifiedThreats: (Record> | null)[]; startingCard: string; gameMode: GameMode; turnDuration: number; diff --git a/src/game/moves.ts b/src/game/moves.ts index 6f78627..a881166 100644 --- a/src/game/moves.ts +++ b/src/game/moves.ts @@ -159,7 +159,7 @@ export const deleteThreat: MoveFn = ( scores[Number.parseInt(playerID)]--; const identifiedThreats = _.cloneDeep(G.identifiedThreats); - delete identifiedThreats[G.selectedDiagram][G.selectedComponent][threat.id]; + delete identifiedThreats[G.selectedDiagram]?.[G.selectedComponent][threat.id]; return { ...G, @@ -191,32 +191,19 @@ export const addOrUpdateThreat: MoveFn = ({ G, playerID }) => { scores[Number.parseInt(playerID)]++; } - // TODO: have a cleaner or readable approach to updating this object - const identifiedThreats = _.cloneDeep(G.identifiedThreats); - - // Are these necessary - if (!(G.selectedDiagram in identifiedThreats)) { - Object.assign(identifiedThreats, { [G.selectedDiagram]: {} }); - } - - if (!(G.selectedComponent in identifiedThreats[G.selectedDiagram])) { - Object.assign(identifiedThreats[G.selectedDiagram], { - [G.selectedComponent]: {}, - }); - } - - //is object.assign required here? - Object.assign(identifiedThreats[G.selectedDiagram][G.selectedComponent], { - [G.threat.id]: { - id: G.threat.id, - owner: G.threat.owner, - title: threatTitle, - type: G.threat.type, - severity: G.threat.severity, - description: threatDescription, - mitigation: threatMitigation || 'No mitigation provided.', + const identifiedThreats = [...G.identifiedThreats]; + identifiedThreats[G.selectedDiagram] = { + ...identifiedThreats[G.selectedDiagram], + [G.selectedComponent]: { + ...G.identifiedThreats[G.selectedDiagram]?.[G.selectedComponent], + [G.threat.id]: { + ...G.threat, + title: threatTitle, + description: threatDescription, + mitigation: threatMitigation || 'No mitigation provided.', + }, }, - }); + }; return { ...G, @@ -232,27 +219,25 @@ export const addOrUpdateThreat: MoveFn = ({ G, playerID }) => { export const draw: MoveFn = ({ G, ctx, events }, card: string) => { const deck = [...G.players[Number.parseInt(ctx.currentPlayer)]]; - let suit = G.suit; + const suit = G.suit; // check if the move is valid if (!getValidMoves(deck, suit, G.round, G.startingCard).includes(card)) { return INVALID_MOVE; } - let dealtBy = G.dealtBy; const index = deck.indexOf(card); deck.splice(index, 1); const dealt = [...G.dealt]; - let numCardsPlayed = G.numCardsPlayed; dealt[parseInt(ctx.currentPlayer)] = card; - numCardsPlayed++; + const numCardsPlayed = G.numCardsPlayed + 1; // only update the suit if no suit exists - if (!suit) suit = card.slice(0, 1) as Suit; + const newSuit = suit ?? (card.slice(0, 1) as Suit); - dealtBy = ctx.currentPlayer; + const dealtBy = ctx.currentPlayer; // move into threats stage events?.setActivePlayers?.({ all: 'threats' }); @@ -260,7 +245,7 @@ export const draw: MoveFn = ({ G, ctx, events }, card: string) => { return { ...G, dealt, - suit, + suit: newSuit, numCardsPlayed, dealtBy, players: { diff --git a/src/game/utils.ts b/src/game/utils.ts index 019e7ca..70e4841 100644 --- a/src/game/utils.ts +++ b/src/game/utils.ts @@ -41,10 +41,7 @@ export function setupGame( round: 1, numCardsPlayed: 0, scores, - lastWinner: getPlayerHoldingStartingCard( - handsPerPlayers, - startingCard, - ) as number, + lastWinner: getPlayerHoldingStartingCard(handsPerPlayers, startingCard), maxRounds: getNumberOfCardsPerHand(handsPerPlayers), selectedDiagram: 0, // as image models or links don't have components, put a dummy id here to treat the entire image as selected @@ -54,7 +51,7 @@ export function setupGame( modal: false, new: true, }, - identifiedThreats: {}, + identifiedThreats: [], startingCard: startingCard, gameMode: gameMode, turnDuration: turnDuration, @@ -192,12 +189,12 @@ export function onTurnEnd({ G, ctx }: FnContext): GameState { } function getWinner( - dealtCards: Card[], + dealtCards: (Card | null)[], currentSuit: Suit, gameMode: GameMode, ): number { const scores = dealtCards.map((card) => - getCardScore(card, currentSuit, gameMode), + card !== null ? getCardScore(card, currentSuit, gameMode) : 0, ); const winner = scores.indexOf(Math.max(...scores)); return winner; diff --git a/src/server/__tests__/server.test.ts b/src/server/__tests__/server.test.ts index 3c1122a..89aca69 100644 --- a/src/server/__tests__/server.test.ts +++ b/src/server/__tests__/server.test.ts @@ -82,8 +82,8 @@ it('download the final model for a game', async () => { G: { modelType: ModelType.THREAT_DRAGON, gameMode: GameMode.EOP, - identifiedThreats: { - 0: { + identifiedThreats: [ + { 'component-1': { 'threat-1': { id: '0', @@ -118,7 +118,7 @@ it('download the final model for a game', async () => { }, }, }, - }, + ], }, } as State; @@ -165,6 +165,10 @@ it('download the final model for a game', async () => { }, ], }, + diagramType: 'STRIDE', + size: { width: 0, height: 0 }, + thumbnail: '', + title: '', }, ], }, @@ -194,8 +198,8 @@ it('Download threat file', async () => { G: { modelType: ModelType.THREAT_DRAGON, gameMode: GameMode.EOP, - identifiedThreats: { - 0: { + identifiedThreats: [ + { 'component-1': { 'threat-1': { id: '0', @@ -231,7 +235,7 @@ it('Download threat file', async () => { }, }, }, - }, + ], }, } as State; @@ -344,6 +348,10 @@ it('Download threat file', async () => { }, ], }, + diagramType: 'STRIDE', + size: { width: 0, height: 0 }, + thumbnail: '', + title: '', }, ], }, diff --git a/src/server/endpoints.ts b/src/server/endpoints.ts index 0b70114..70b52d4 100644 --- a/src/server/endpoints.ts +++ b/src/server/endpoints.ts @@ -131,6 +131,7 @@ export const createGame = } }; +// TODO: This returns more than just the players: It returns the requested match. We should probably adjust the name (and the path). See https://boardgame.io/documentation/#/api/Lobby?id=getting-a-specific-match-by-its-id export const getPlayerNames = (): IMiddleware => async (ctx) => { const matchID = ctx.params.matchID; @@ -209,49 +210,40 @@ export const downloadThreatDragonModel = } // update the model with the identified threats - Object.keys(state.G.identifiedThreats).forEach((diagramIdx) => { - Object.keys(state.G.identifiedThreats[diagramIdx]).forEach( - (componentIdx) => { - const diagram = - model.detail.diagrams[Number.parseInt(diagramIdx)].diagramJson; - let cell = null; - for (let i = 0; i < diagram.cells.length; i++) { - const c = diagram.cells[i]; - if (c.id === componentIdx) { - cell = c; - break; - } - } - if (cell !== null) { - let threats: ThreatDragonThreat[] = []; - if (Array.isArray(cell.threats)) { - threats = cell.threats; - } - Object.keys( - state.G.identifiedThreats[diagramIdx][componentIdx], - ).forEach((threatIdx) => { - const t = - state.G.identifiedThreats[diagramIdx][componentIdx][threatIdx]; + state.G.identifiedThreats.forEach((threatsForComponent, diagramIdx) => { + if (threatsForComponent === null) { + return; + } + Object.keys(threatsForComponent).forEach((componentIdx) => { + const diagram = model.detail.diagrams[diagramIdx].diagramJson; + const cell = diagram.cells?.find((c) => c.id === componentIdx); + + if (cell !== undefined) { + const threats: ThreatDragonThreat[] = cell.threats ?? []; + Object.keys(threatsForComponent[componentIdx]).forEach( + (threatIdx) => { + const t = threatsForComponent[componentIdx][threatIdx]; threats.push({ + description: t.description ?? '', + mitigation: t.mitigation ?? '', + modelType: getMethodologyName(state.G.gameMode), + severity: t.severity ?? '', status: 'Open', - severity: t.severity, - id: t.id, - methodology: getMethodologyName(state.G.gameMode), + title: t.title ?? '', type: getSuitDisplayName(state.G.gameMode, t.type), - title: t.title, - description: t.description, - mitigation: t.mitigation, + owner: t.owner !== undefined ? game.metadata.players[Number.parseInt(t.owner)]?.name : undefined, + id: t.id, game: matchID, }); - }); - cell.threats = threats; - } - }, - ); + }, + ); + cell.threats = threats; + } + }); }); const modelTitle = model.summary.title.replace(' ', '-'); @@ -292,8 +284,8 @@ export const downloadThreatsMarkdownFile = ? game.model?.summary.title.trim().replaceAll(' ', '-') : `` : game.state.G.gameMode - ? game.state.G.gameMode.trim().replaceAll(' ', '') - : ``; + ? game.state.G.gameMode.trim().replaceAll(' ', '') + : ``; const timestamp = new Date().toISOString().replaceAll(':', '-'); const date = new Date().toLocaleString(); const filename = `threats-${modelTitle}-${timestamp}.md`; @@ -355,7 +347,7 @@ function getThreats( //add threats from model if (model) { model.detail.diagrams.forEach((diagram) => { - diagram.diagramJson.cells.forEach((cell) => { + diagram.diagramJson.cells?.forEach((cell) => { if (cell.threats !== undefined) { threats.push(...cell.threats); } diff --git a/src/server/filesystem.ts b/src/server/filesystem.ts index 4b28499..dd4f572 100644 --- a/src/server/filesystem.ts +++ b/src/server/filesystem.ts @@ -2,32 +2,32 @@ import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; - -const DB_SUBFOLDER = `db` -const DB_IMAGES_SUBFOLDER = `db-images` +const DB_SUBFOLDER = `db`; +const DB_IMAGES_SUBFOLDER = `db-images`; const ensureExists = (subfolder: string): string => { const fullPath = path.join(os.tmpdir(), subfolder); - + if (!fs.existsSync(fullPath)) { fs.mkdirSync(fullPath); } if (!fs.lstatSync(fullPath).isDirectory()) { - throw new Error(`Conflict: Expected ${fullPath} to be a directory, but is a file.`); + throw new Error( + `Conflict: Expected ${fullPath} to be a directory, but is a file.`, + ); } // Fail fast: if another user already created this subfolder (i.e. for information disclosure), this will fail and the application will not start fs.chmodSync(fullPath, 0o700); return fullPath; - -} +}; export const getDbFolder = (): string => { return ensureExists(DB_SUBFOLDER); -} +}; export const getDbImagesFolder = (): string => { return ensureExists(DB_IMAGES_SUBFOLDER); -} +}; diff --git a/src/server/publicApi.ts b/src/server/publicApi.ts index 91bccce..33e7aef 100644 --- a/src/server/publicApi.ts +++ b/src/server/publicApi.ts @@ -29,7 +29,10 @@ const runPublicApi = (gameServer: GameServer): [Koa, Server] => { router.post( '/create', - koaBody({ multipart: true, formidable: { uploadDir: getDbImagesFolder() } }), + koaBody({ + multipart: true, + formidable: { uploadDir: getDbImagesFolder() }, + }), createGame(gameServer), ); router.get('/:matchID/players', getPlayerNames()); diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/src/types/ThreatDragonModel.ts b/src/types/ThreatDragonModel.ts index a50235f..c96abe4 100644 --- a/src/types/ThreatDragonModel.ts +++ b/src/types/ThreatDragonModel.ts @@ -1,38 +1,330 @@ -export type ThreatDragonModel = { +/** + * The threat models used by OWASP Threat Dragon + * + * This type is based on the schema at https://owasp.org/www-project-threat-dragon/assets/schemas/owasp.threat-dragon.schema.V1.json + */ +export interface ThreatDragonModel { + /** + * Threat Dragon version used in the model + */ + version?: string; + + /** + * Threat model project meta-data + */ summary: { + /** + * Description of the threat model used for report outputs + */ description?: string; + /** + * A unique identifier for this main threat model object + */ id?: number; + /** + * The original creator or overall owner of the model + */ owner?: string; - tdVersion?: string; + /** + * Threat model title + */ title: string; }; + /** + * Threat model definition + */ detail: { + /** + * An array of contributors to the threat model project + */ contributors?: Contributor[]; + /** + * An array of single or multiple threat data-flow diagrams + */ diagrams: Diagram[]; }; -}; +} interface Contributor { + /** + * The name of the contributor + */ name: string; } -interface Size { - width: number; - height: number; -} - interface Diagram { - diagramJson: DiagramJson; - diagramType?: string; + /** + * The methodology used by the data-flow diagram + */ + diagramType: string; + /** + * The sequence number of the diagram + */ id: number; - thumbnail?: string; - title?: string; - size?: Size; - $$hashKey?: string; + /** + * The size of the diagram drawing canvas + */ + size: { + /** + * The height of the diagram drawing canvas + */ + height: number; + /** + * The width of the diagram drawing canvas + */ + width: number; + }; + /** + * The path to the thumbnail image for the diagram + */ + thumbnail: string; + /** + * The title of the data-flow diagram + */ + title: string; + /** + * Threat Dragon version used in the diagram + */ + version?: string; + /** + * The data-flow diagram components + */ + diagramJson: DiagramJson; + /** + * The highest diagram number in the threat model + */ + diagramTop?: number; + /** + * The reviewer of the overall threat model + */ + reviewer?: string; + /** + * The highest threat number in the threat model + */ + threatTop?: number; } interface DiagramJson { - cells: Cell[]; + /** + * The individual diagram components + */ + cells?: ThreatDragonComponent[]; +} + +export interface ThreatDragonComponent { + /** + * The component display attributes + */ + attrs: { + /** + * The component shape attributes + */ + '.element-shape'?: { + /** + * The component shape display attributes + */ + class?: string; + }; + /** + * The component text + */ + text?: { + /** + * The component text contents + */ + text: string; + }; + /** + * The component text attributes + */ + '.element-text'?: { + /** + * The component text display attributes + */ + class?: string; + }; + }; + /** + * The component rotation angle + */ + angle?: number; + /** + * The component description + */ + description?: string; + /** + * The component flag set if the process handles credit card payment + */ + handlesCardPayment?: boolean; + /** + * The component flag set if the process is part of a retail site + */ + handlesGoodsOrServices?: boolean; + /** + * The component flag set if there are open threats + */ + hasOpenThreats?: boolean; + /** + * The component unique identifier (UUID) + */ + id: string; + /** + * The component flag set if the store contains logs + */ + isALog?: boolean; + /** + * The component flag set if the process is a web application + */ + isWebApplication?: boolean; + /** + * The component flag set if the data flow or store is encrypted + */ + isEncrypted?: boolean; + /** + * The component flag set if the data store uses signatures + */ + isSigned?: boolean; + /** + * The flag set if the component is a trust boundary curve or trust boundary box + */ + isTrustBoundary?: boolean; + /** + * The floating labels used for boundary or data-flow + */ + labels?: Label[]; + /** + * The component flag set if it is not in scope + */ + outOfScope?: boolean; + /** + * The component position + */ + position?: { + /** + * The component horizontal position + */ + x: number; + /** + * The component vertical position + */ + y: number; + [k: string]: unknown; + }; + /** + * The component's level of privilege/permissions + */ + privilegeLevel?: string; + /** + * The component description if out of scope + */ + reasonOutOfScope?: string; + /** + * The component size + */ + size: { + /** + * The component height + */ + height: number; + /** + * The component width + */ + width: number; + }; + /** + * The component curve type, for data flows and boundaries + */ + smooth?: boolean; + /** + * The component curve source + */ + source?: { + /** + * The data-flow source component + */ + id?: string; + /** + * The boundary horizontal curve source + */ + x?: number; + /** + * The boundary vertical curve source + */ + y?: number; + }; + /** + * The component flag set if store contains credentials/PII + */ + storesCredentials?: boolean; + /** + * The component flag set if store is part of a retail web application + */ + storesInventory?: boolean; + /** + * The component curve target + */ + target?: { + /** + * The data-flow target component + */ + id?: string; + /** + * The boundary horizontal curve target + */ + x?: number; + /** + * The boundary vertical curve target + */ + y?: number; + }; + /** + * The threats associated with the component + */ + threats?: ThreatDragonThreat[]; + type: CellType; + /** + * The boundary or data-flow curve points + */ + vertices?: { + /** + * The horizontal value of the curve point + */ + x: number; + /** + * The vertical value of the curve point + */ + y: number; + }; + z: number; +} + +interface Label { + /** + * The label position + */ + position: number; + /** + * The label text attributes + */ + attrs: { + /** + * The text attributes + */ + text: { + /** + * The text size + */ + 'font-size': string; + /** + * The text weight + */ + 'font-weight': string; + /** + * The text content + */ + text: string; + }; + }; } type CellType = @@ -42,36 +334,52 @@ type CellType = | 'tm.Flow' | 'tm.Boundary'; -interface Position { - x: number; - y: number; -} - -interface Cell { - type: CellType; - size: Size; - position: Position; - angle: number; - z: number; - attrs: Record>; - id: string; - hasOpenThreats: boolean; - threats?: ThreatDragonThreat[]; -} - -type Status = 'Open' | 'Mitigated'; - export interface ThreatDragonThreat { + /** + * The threat description + */ + description: string; + /** + * The threat mitigation + */ + mitigation: string; + /** + * The threat methodology type + */ + modelType?: string; + /** + * The unique number for the threat + */ + number?: number; + /** + * The custom score/risk for the threat + */ + score?: string; + /** + * The threat severity as High, Medium or Low + */ + severity: string; + /** + * The threat status as NA, Open or Mitigated + */ status: Status; - severity?: string; - mitigation?: string; - description?: string; - owner?: string; - title?: string; - type?: string; + /** + * The threat ID as a UUID + */ + threatId?: string; + /** + * The threat title + */ + title: string; + /** + * The threat type, selection according to modelType + */ + type: string; // our custom properties + owner?: string; id?: string; - methodology?: string; game?: string; } + +type Status = 'NA' | 'Open' | 'Mitigated'; diff --git a/src/types/reactstrap-confirm.d.ts b/src/types/reactstrap-confirm.d.ts new file mode 100644 index 0000000..97171a6 --- /dev/null +++ b/src/types/reactstrap-confirm.d.ts @@ -0,0 +1,19 @@ +declare module 'reactstrap-confirm' { + type Props = { + message?: React.ReactNode; + title?: React.ReactNode; + confirmTex?: React.ReactNode; + cancelText?: React.ReactNode; + confirmColor?: 'string'; + cancelColor?: string; + className?: string; + size?: string | null; + buttonsComponent?: React.ComponentType | null; + bodyComponent?: React.ComponentType | null; + modalProps?: React.ComponentProps; + }; + + const confirm: (props?: Props) => Promise; + + export default confirm; +} diff --git a/src/utils/GameMode.ts b/src/utils/GameMode.ts index 147b183..901b136 100644 --- a/src/utils/GameMode.ts +++ b/src/utils/GameMode.ts @@ -19,7 +19,7 @@ export function getCardCssClass(gameMode: GameMode, c: Card): string { } if (isGameModeElevationOfMlSec(gameMode)) { - return `eomlsec-card eomlsec-card-img-${c.toLowerCase()}` + return `eomlsec-card eomlsec-card-img-${c.toLowerCase()}`; } return `eop-card eop-card-img-${c.toLowerCase()}`; diff --git a/src/utils/__tests__/utils.test.js b/src/utils/__tests__/utils.test.ts similarity index 55% rename from src/utils/__tests__/utils.test.js rename to src/utils/__tests__/utils.test.ts index d818e22..645f293 100644 --- a/src/utils/__tests__/utils.test.js +++ b/src/utils/__tests__/utils.test.ts @@ -1,5 +1,7 @@ -import { getSuitDisplayName } from '../cardDefinitions'; -import { STARTING_CARD } from '../constants'; +import type { GameState } from '../../game/gameState'; +import { DEFAULT_GAME_MODE } from '../GameMode'; +import { getStartingCard } from '../cardDefinitions'; +import { DEFAULT_START_SUIT, ModelType } from '../constants'; import { escapeMarkdownText, getComponentName, @@ -11,19 +13,40 @@ import { resolvePlayerNames, } from '../utils'; -it('getDealtCard() should get empty card if no card dealt', async () => { - const G = { - dealt: [], - dealtBy: '', - }; +const baseG: GameState = { + dealt: [], + dealtBy: '', + passed: [], + suit: undefined, + players: [['T2'], ['T3'], ['T4']], + round: 0, + numCardsPlayed: 0, + scores: [0, 0], + lastWinner: 0, + maxRounds: 10, + selectedDiagram: 0, + selectedComponent: 'some-component', + selectedThreat: 'some-threat', + threat: { + modal: false, + new: true, + }, + identifiedThreats: [], + startingCard: 'the starting card', + gameMode: DEFAULT_GAME_MODE, + turnDuration: 0, + modelType: ModelType.PRIVACY_ENHANCED, +}; - const dealtCard = getDealtCard(G); +it('getDealtCard() should get empty card if no card dealt', async () => { + const dealtCard = getDealtCard(baseG); expect(dealtCard).toBe(''); }); it('getDealtCard() should get correct card if a single card is dealt', async () => { - const G = { + const G: GameState = { + ...baseG, dealt: [null, null, 'E3'], dealtBy: '2', }; @@ -34,7 +57,8 @@ it('getDealtCard() should get correct card if a single card is dealt', async () }); it('getDealtCard() should get correct card if a multiple cards are dealt', async () => { - const G = { + const G: GameState = { + ...baseG, dealt: ['E8', 'EA', 'E3'], dealtBy: '1', }; @@ -46,15 +70,9 @@ it('getDealtCard() should get correct card if a multiple cards are dealt', async it('resolves player names correctly', async () => { const names = ['foo', 'bar', 'baz']; - expect(resolvePlayerNames([0, 1, 2], names)).toStrictEqual(names); + expect(resolvePlayerNames(['0', '1', '2'], names, null)).toStrictEqual(names); - expect(resolvePlayerNames([1, 0, 2], names)).toStrictEqual([ - 'bar', - 'foo', - 'baz', - ]); - - expect(resolvePlayerNames([1, 0, 2], names, 1)).toStrictEqual([ + expect(resolvePlayerNames(['1', '0', '2'], names, '1')).toStrictEqual([ 'You', 'foo', 'baz', @@ -63,9 +81,9 @@ it('resolves player names correctly', async () => { it('resolves player name correctly', async () => { const names = ['foo', 'bar', 'baz']; - expect(resolvePlayerName(0, names)).toBe(names[0]); + expect(resolvePlayerName('0', names, null)).toBe(names[0]); - expect(resolvePlayerName(0, names, 0)).toBe('You'); + expect(resolvePlayerName('0', names, '0')).toBe('You'); }); it('gammer joins correctly', async () => { @@ -80,17 +98,20 @@ it('creates player array correctly', async () => { }); it('makes correct component name', async () => { - expect(getComponentName(null)).toBe(''); + expect(getComponentName(undefined)).toBe(''); expect( getComponentName({ - type: 'tm.Foo', + type: 'tm.Actor', attrs: { text: { text: 'Bar', }, }, + id: 'some-id', + size: { width: 0, height: 0 }, + z: 0, }), - ).toBe('Foo: Bar'); + ).toBe('Actor: Bar'); expect( getComponentName({ type: 'tm.Flow', @@ -99,29 +120,39 @@ it('makes correct component name', async () => { attrs: { text: { text: 'Bar', + 'font-size': '12pt', + 'font-weight': 'bold', }, }, + position: 0, }, ], + attrs: {}, + id: 'some-id', + size: { width: 0, height: 0 }, + z: 0, }), ).toBe('Flow: Bar'); }); it('produces valid moves', async () => { - expect(getValidMoves([], '', 0)).toStrictEqual([STARTING_CARD]); - - expect(getValidMoves(['T4', 'S2', 'EA', 'T5'], 'T', 10)).toStrictEqual([ - 'T4', - 'T5', + const startingCard = getStartingCard(DEFAULT_GAME_MODE, DEFAULT_START_SUIT); + expect(getValidMoves([], undefined, 0, startingCard)).toStrictEqual([ + startingCard, ]); - expect(getValidMoves(['S2', 'EA'], 'T', 10)).toStrictEqual(['S2', 'EA']); -}); + expect( + getValidMoves(['T4', 'S2', 'EA', 'T5'], 'T', 10, startingCard), + ).toStrictEqual(['T4', 'T5']); -it('produces correct type string', async () => { - expect(getSuitDisplayName('FOO')).toBe(''); + expect(getValidMoves(['S2', 'EA'], 'T', 10, startingCard)).toStrictEqual([ + 'S2', + 'EA', + ]); }); +// TODO: add proper tests getSuitDisplayName + it('successfully escapes any malicious markdown text', () => { expect( escapeMarkdownText( diff --git a/src/utils/cardDefinitions.ts b/src/utils/cardDefinitions.ts index 2f63a79..8d6f140 100644 --- a/src/utils/cardDefinitions.ts +++ b/src/utils/cardDefinitions.ts @@ -467,7 +467,7 @@ const CARD_DECKS: CardDeckDefinitions = { ], isTrump: true, isDefault: false, - } + }, }, }; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 6b07da0..39e54ae 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -22,9 +22,9 @@ export const DEFAULT_MODEL: ThreatDragonModel = { diagrams: [ { title: 'Threat Modelling', + thumbnail: '', diagramType: 'STRIDE', id: 0, - $$hashKey: 'object:14', diagramJson: { cells: [ { @@ -64,4 +64,4 @@ export const DEFAULT_MODEL: ThreatDragonModel = { }, }; -export const SPECTATOR = `spectator`; +export const SPECTATOR = 'spectator'; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 651110c..d8749d2 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -2,10 +2,11 @@ import type { PlayerID } from 'boardgame.io'; import type { GameState } from '../game/gameState'; import type { Card, Suit } from './cardDefinitions'; import { ModelType } from './constants'; +import type { ThreatDragonComponent } from '../types/ThreatDragonModel'; export function getDealtCard(G: GameState): string { if (G.dealt.length > 0 && G.dealtBy) { - return G.dealt[Number.parseInt(G.dealtBy)]; + return G.dealt[Number.parseInt(G.dealtBy)] ?? ''; } return ''; } @@ -13,12 +14,14 @@ export function getDealtCard(G: GameState): string { export function resolvePlayerNames( players: PlayerID[], names: string[], - current: PlayerID, + current: PlayerID | null, ): string[] { const resolved = []; for (let i = 0; i < players.length; i++) { const c = Number.parseInt(players[i]); - resolved.push(c === Number.parseInt(current) ? 'You' : names[c]); + resolved.push( + current !== null && c === Number.parseInt(current) ? 'You' : names[c], + ); } return resolved; } @@ -26,9 +29,10 @@ export function resolvePlayerNames( export function resolvePlayerName( player: PlayerID, names: string[], - current: PlayerID, + current: PlayerID | null, ): string { - return Number.parseInt(player) === Number.parseInt(current) + return current !== null && + Number.parseInt(player) === Number.parseInt(current) ? 'You' : names[Number.parseInt(player)]; } @@ -49,18 +53,18 @@ export function getPlayers(count: number): string[] { return players; } -// FIXME: Improve typing of model / component -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types -export function getComponentName(component: any): string { - if (component === null) return ''; +export function getComponentName( + component: ThreatDragonComponent | undefined, +): string { + if (component === undefined) return ''; - const prefix = component.type.substr(3); + const prefix = component.type.slice(3); if (component.type === 'tm.Flow') { - return `${prefix}: ${component.labels[0].attrs.text.text}`; + return `${prefix}: ${component.labels?.[0].attrs.text.text}`; } - return `${prefix}: ${component.attrs.text.text}`; + return `${prefix}: ${component.attrs.text?.text}`; } export function getValidMoves( diff --git a/tsconfig.json b/tsconfig.json index f28ad1f..ae88000 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2015", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true,