diff --git a/.github/workflows/integrationTest.yml b/.github/workflows/integrationTest.yml new file mode 100644 index 0000000..a217b2b --- /dev/null +++ b/.github/workflows/integrationTest.yml @@ -0,0 +1,19 @@ +name: Recovery Signer Integration Test +on: [pull_request] +jobs: + test-ci: + name: integration test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Start docker + run: docker-compose -f test/docker/docker-compose.yml up -d + - uses: actions/setup-node@v2 + with: + node-version: 18 + - run: yarn install + - run: yarn build + - run: yarn test:integration:ci + - name: Print Docker Logs + if: always() # This ensures that the logs are printed even if the tests fail + run: docker-compose -f test/docker/docker-compose.yml logs diff --git a/jest.config.js b/jest.config.js index 5191e9f..e4180ff 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,4 +5,5 @@ module.exports = { "^.+\\.(ts|tsx)?$": "ts-jest", "^.+\\.(js|jsx)$": "babel-jest", }, + testPathIgnorePatterns: ["/node_modules/", "integration.test.ts"], }; diff --git a/jest.integration.config.js b/jest.integration.config.js new file mode 100644 index 0000000..bc33dbf --- /dev/null +++ b/jest.integration.config.js @@ -0,0 +1,9 @@ +module.exports = { + rootDir: "./", + preset: "ts-jest", + transform: { + "^.+\\.(ts|tsx)?$": "ts-jest", + "^.+\\.(js|jsx)$": "babel-jest", + }, + testMatch: ["**/*integration.test.ts"], +}; diff --git a/package.json b/package.json index ff128ef..13137d9 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "prepare": "husky install", "test": "jest --watchAll", "test:ci": "jest --ci", + "test:integration:ci": "jest --config jest.integration.config.js --ci", "build:web": "webpack --config webpack.config.js", "build:node": "webpack --env NODE=true --config webpack.config.js", "build": "run-p build:web build:node", diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..864bbed --- /dev/null +++ b/test/README.md @@ -0,0 +1,18 @@ +# Recovery Integration Tests + +## How it works + +The recovery integration tests run different recovery scenarios against recovery +signer and webauth servers. 2 recovery signer and 2 webauth servers are started +in a docker-compose file (see test/docker/docker-compose.yml), to simulate a +wallet interacting with 2 separate recovery servers. + +## To run tests locally: + +``` +// start servers using docker +$ docker-compose -f test/docker/docker-compose.yml up + +// run tests +$ yarn test:integration:ci +``` diff --git a/test/docker/docker-compose.yml b/test/docker/docker-compose.yml new file mode 100644 index 0000000..93914ae --- /dev/null +++ b/test/docker/docker-compose.yml @@ -0,0 +1,97 @@ +version: "3" +services: + recovery-signer-migrate1: + image: stellar/recoverysigner:latest + depends_on: + postgres1: + condition: service_healthy + restart: on-failure + command: ["db", "migrate", "up"] + environment: + DB_URL: "postgresql://postgres:pg_password@postgres1:5432/pg_database1?sslmode=disable" + + recovery-signer-migrate2: + image: stellar/recoverysigner:latest + depends_on: + postgres2: + condition: service_healthy + restart: on-failure + command: ["db", "migrate", "up"] + environment: + DB_URL: "postgresql://postgres:pg_password@postgres2:5432/pg_database2?sslmode=disable" + + recovery-signer1: + image: stellar/recoverysigner:latest + ports: + - "8000:8000" + depends_on: + - postgres1 + environment: + DB_URL: "postgresql://postgres:pg_password@postgres1:5432/pg_database1?sslmode=disable" + SIGNING_KEY: SAQFNCKPZ3ON5TSSEURAF4NPTZONPA37JPHQNHSLSRUNFP43MMT5LNH6 + FIREBASE_PROJECT_ID: "none" + SEP10_JWKS: '{"keys":[{"kty":"EC","crv":"P-256","alg":"ES256","x":"dzqvhrMYwbmv7kcZK6L1oOATMFXG9wLFlnKfHf3E7FM","y":"Vb_wmcX-Zq2Hg2LFoXCEVWMwdJ01q41pSnxC3psunUY"}]}' + PORT: 8000 + + recovery-signer2: + image: stellar/recoverysigner:latest + ports: + - "8002:8002" + depends_on: + - postgres2 + environment: + DB_URL: "postgresql://postgres:pg_password@postgres2:5432/pg_database2?sslmode=disable" + SIGNING_KEY: SA3Y2KQCPN6RAKLUISMY252QABWPQ3A65FBMZO2JJFKJ7O7VJNQ2PRYH # Use a different key for the second recovery signer + FIREBASE_PROJECT_ID: "none" + SEP10_JWKS: '{"keys":[{"kty":"EC","crv":"P-256","alg":"ES256","x":"dzqvhrMYwbmv7kcZK6L1oOATMFXG9wLFlnKfHf3E7FM","y":"Vb_wmcX-Zq2Hg2LFoXCEVWMwdJ01q41pSnxC3psunUY"}]}' + PORT: 8002 + + web-auth1: + image: stellar/webauth:latest + ports: + - "8001:8001" + environment: + SIGNING_KEY: SDYHSG4V2JP5H66N2CXBFCOBTAUFWXGJVPKWY6OXSIPMYW743N62QX6U + JWK: '{"kty":"EC","crv":"P-256","alg":"ES256","x":"dzqvhrMYwbmv7kcZK6L1oOATMFXG9wLFlnKfHf3E7FM","y":"Vb_wmcX-Zq2Hg2LFoXCEVWMwdJ01q41pSnxC3psunUY","d":"ivOMB4Wscz8ShvhwWDRyd-JJVfSMsjsz1oU3sNc-XJo"}' + DOMAIN: test-domain + AUTH_HOME_DOMAIN: test-domain + JWT_ISSUER: test + PORT: 8001 + + web-auth2: + image: stellar/webauth:latest + ports: + - "8003:8003" + environment: + SIGNING_KEY: SCAS7BUKVDL44A2BAP23RVAM6XXHB24YRCANQGDTP24HP7T6LPUFIGGU # Use a different key for the second web auth server + JWK: '{"kty":"EC","crv":"P-256","alg":"ES256","x":"dzqvhrMYwbmv7kcZK6L1oOATMFXG9wLFlnKfHf3E7FM","y":"Vb_wmcX-Zq2Hg2LFoXCEVWMwdJ01q41pSnxC3psunUY","d":"ivOMB4Wscz8ShvhwWDRyd-JJVfSMsjsz1oU3sNc-XJo"}' + DOMAIN: test-domain + AUTH_HOME_DOMAIN: test-domain + JWT_ISSUER: test + PORT: 8003 + + postgres1: + image: postgres:14 + environment: + POSTGRES_PASSWORD: pg_password + POSTGRES_DB: pg_database1 + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + postgres2: + image: postgres:14 + environment: + POSTGRES_PASSWORD: pg_password + POSTGRES_DB: pg_database2 + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/test/integration.test.ts b/test/integration.test.ts new file mode 100644 index 0000000..3a8e47e --- /dev/null +++ b/test/integration.test.ts @@ -0,0 +1,166 @@ +import axios from "axios"; +import { Wallet } from "../src"; + +import { + RecoveryServer, + RecoveryServerKey, + RecoveryServerMap, + RecoverableWalletConfig, + RecoveryRole, + RecoveryAccountIdentity, + RecoveryType, +} from "../src/walletSdk/Types/recovery"; + +describe("Recovery Integration Tests", () => { + it("should work", async () => { + const wallet = Wallet.TestNet(); + const stellar = wallet.stellar(); + const accountService = stellar.account(); + + const server1Key: RecoveryServerKey = "server1"; + const server1: RecoveryServer = { + endpoint: "http://localhost:8000", + authEndpoint: "http://localhost:8001", + homeDomain: "test-domain", + }; + + const server2Key: RecoveryServerKey = "server2"; + const server2: RecoveryServer = { + endpoint: "http://localhost:8002", + authEndpoint: "http://localhost:8003", + homeDomain: "test-domain", + }; + + const servers: RecoveryServerMap = { + [server1Key]: server1, + [server2Key]: server2, + }; + + const recovery = wallet.recovery({ servers }); + + // Create accounts + + const accountKp = accountService.createKeypair(); + const deviceKp = accountService.createKeypair(); + const recoveryKp = accountService.createKeypair(); + + try { + await stellar.server.loadAccount(accountKp.publicKey); + await stellar.server.loadAccount(deviceKp.publicKey); + await stellar.server.loadAccount(recoveryKp.publicKey); + } catch (e) { + await axios.get( + "https://friendbot.stellar.org/?addr=" + accountKp.publicKey, + ); + await axios.get( + "https://friendbot.stellar.org/?addr=" + deviceKp.publicKey, + ); + await axios.get( + "https://friendbot.stellar.org/?addr=" + recoveryKp.publicKey, + ); + } + + // Create SEP-30 identities + + const identity1: RecoveryAccountIdentity = { + role: RecoveryRole.OWNER, + authMethods: [ + { + type: RecoveryType.STELLAR_ADDRESS, + value: recoveryKp.publicKey, + }, + ], + }; + + const identity2: RecoveryAccountIdentity = { + role: RecoveryRole.OWNER, + authMethods: [ + { + type: RecoveryType.STELLAR_ADDRESS, + value: recoveryKp.publicKey, + }, + { + type: RecoveryType.EMAIL, + value: "my-email@example.com", + }, + ], + }; + + // Create recoverable wallet + + const config: RecoverableWalletConfig = { + accountAddress: accountKp, + deviceAddress: deviceKp, + accountThreshold: { low: 10, medium: 10, high: 10 }, + accountIdentity: { [server1Key]: [identity1], [server2Key]: [identity2] }, + signerWeight: { device: 10, recoveryServer: 5 }, + }; + const recoverableWallet = await recovery.createRecoverableWallet(config); + + // Sign and submit + + recoverableWallet.transaction.sign(accountKp.keypair); + await stellar.submitTransaction(recoverableWallet.transaction); + + let resp = await stellar.server.loadAccount(accountKp.publicKey); + + expect(resp.signers.map((obj) => obj.weight).sort((a, b) => a - b)).toEqual( + [0, 5, 5, 10], + ); + expect( + resp.signers.find((obj) => obj.key === accountKp.publicKey).weight, + ).toBe(0); + expect( + resp.signers.find((obj) => obj.key === deviceKp.publicKey).weight, + ).toBe(10); + + // Get Account Info + + const authToken1 = await recovery + .sep10Auth(server1Key) + .authenticate({ accountKp: recoveryKp }); + + const authMap = { [server1Key]: authToken1 }; + + const accountResp = await recovery.getAccountInfo(accountKp, authMap); + expect(accountResp[server1Key].address).toBe(accountKp.publicKey); + expect(accountResp[server1Key].identities[0].role).toBe("owner"); + expect(accountResp[server1Key].signers.length).toBe(1); + + // Recover Wallet + + const authToken2 = await recovery + .sep10Auth(server2Key) + .authenticate({ accountKp: recoveryKp }); + + const recoverySignerAddress1 = recoverableWallet.signers[0]; + const recoverySignerAddress2 = recoverableWallet.signers[1]; + const newKp = accountService.createKeypair(); + const signerMap = { + [server1Key]: { + signerAddress: recoverySignerAddress1, + authToken: authToken1, + }, + [server2Key]: { + signerAddress: recoverySignerAddress2, + authToken: authToken2, + }, + }; + const recoverTxn = await recovery.replaceDeviceKey( + accountKp, + newKp, + signerMap, + ); + + await stellar.submitTransaction(recoverTxn); + + resp = await stellar.server.loadAccount(accountKp.publicKey); + + expect( + resp.signers.find((obj) => obj.key === deviceKp.publicKey), + ).toBeFalsy(); + expect(resp.signers.find((obj) => obj.key === newKp.publicKey).weight).toBe( + 10, + ); + }, 60000); +});