diff --git a/.env b/.env index 5531bf63cf..fe5c955869 100644 --- a/.env +++ b/.env @@ -87,7 +87,7 @@ export LOG_LEVEL="info" export KRATOS_MASTER_USER_PASSWORD="passwordHardtoFindWithNumber123" export KRATOS_PG_HOST="localhost" -export KRATOS_PG_PORT="5433" +export KRATOS_PG_PORT="5432" export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" # TODO: rename to OTEL_SERVICE_NAME @@ -96,7 +96,7 @@ export TRACING_SERVICE_NAME="galoy-dev" export MATTERMOST_WEBHOOK_URL="https://chat.galoy.io/hooks/sometoken" -export KRATOS_PG_CON="postgres://dbuser:secret@localhost:5433/default?sslmode=disable" +export KRATOS_PG_CON="postgres://dbuser:secret@localhost:5432/default?sslmode=disable" export UNSECURE_DEFAULT_LOGIN_CODE="000000" export UNSECURE_IP_FROM_REQUEST_OBJECT=true diff --git a/core/api/.env b/core/api/.env index aaf2262d10..75c2e79664 100644 --- a/core/api/.env +++ b/core/api/.env @@ -88,7 +88,7 @@ export LOG_LEVEL="info" export KRATOS_MASTER_USER_PASSWORD="passwordHardtoFindWithNumber123" export KRATOS_ADMIN_URL="http://localhost:4434" export KRATOS_PG_HOST="localhost" -export KRATOS_PG_PORT="5433" +export KRATOS_PG_PORT="5432" export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" # TODO: rename to OTEL_SERVICE_NAME @@ -97,7 +97,7 @@ export TRACING_SERVICE_NAME="galoy-dev" export MATTERMOST_WEBHOOK_URL="https://chat.galoy.io/hooks/sometoken" -export KRATOS_PG_CON="postgres://dbuser:secret@localhost:5433/default?sslmode=disable" +export KRATOS_PG_CON="postgres://dbuser:secret@localhost:5432/default?sslmode=disable" export UNSECURE_DEFAULT_LOGIN_CODE="000000" export UNSECURE_IP_FROM_REQUEST_OBJECT=true diff --git a/core/api/src/domain/authentication/index.types.d.ts b/core/api/src/domain/authentication/index.types.d.ts index 509d48d30e..e40fcc3fa1 100644 --- a/core/api/src/domain/authentication/index.types.d.ts +++ b/core/api/src/domain/authentication/index.types.d.ts @@ -24,21 +24,39 @@ type IdentityPhone = IdentityBase & { phone: PhoneNumber email: undefined emailVerified: undefined + + username?: undefined } type IdentityEmail = IdentityBase & { phone: undefined email: EmailAddress emailVerified: boolean + + username?: undefined } type IdentityPhoneEmail = IdentityBase & { phone: PhoneNumber email: EmailAddress emailVerified: boolean + + username?: undefined +} + +type IdentityDeviceAccount = IdentityBase & { + username: IdentityUsername + + phone?: undefined + email?: undefined + emailVerified?: undefined } -type AnyIdentity = IdentityPhone | IdentityEmail | IdentityPhoneEmail +type AnyIdentity = + | IdentityPhone + | IdentityEmail + | IdentityPhoneEmail + | IdentityDeviceAccount type Session = { identity: AnyIdentity diff --git a/core/api/src/services/kratos/identity.ts b/core/api/src/services/kratos/identity.ts index 530fa3374f..ec81526483 100644 --- a/core/api/src/services/kratos/identity.ts +++ b/core/api/src/services/kratos/identity.ts @@ -8,6 +8,20 @@ import { getKratosPostgres, kratosAdmin, toDomainIdentity } from "./private" import { IdentifierNotFoundError } from "@/domain/authentication/errors" import { ErrorLevel } from "@/domain/shared" +export const getNextPageToken = (link: string): string | undefined => { + const links = link.split(",") + const nextLink = links.find((link) => link.includes('rel="next"')) + + if (nextLink) { + const matches = nextLink.match(/page_token=([^;&>]+)/) + if (matches) { + return matches[1] + } + } + + return undefined +} + export const IdentityRepository = (): IIdentityRepository => { const getIdentity = async ( kratosUserId: UserId, @@ -109,17 +123,3 @@ export const IdentityRepository = (): IIdentityRepository => { getUserIdFromFlowId, } } - -export const getNextPageToken = (link: string): string | undefined => { - const links = link.split(",") - const nextLink = links.find((link) => link.includes('rel="next"')) - - if (nextLink) { - const matches = nextLink.match(/page_token=([^;&>]+)/) - if (matches) { - return matches[1] - } - } - - return undefined -} diff --git a/core/api/src/services/kratos/private.ts b/core/api/src/services/kratos/private.ts index b1ab4e1c40..bf7abad862 100644 --- a/core/api/src/services/kratos/private.ts +++ b/core/api/src/services/kratos/private.ts @@ -72,10 +72,12 @@ export const toDomainIdentity = (identity: KratosIdentity): AnyIdentity => { createdAt = new Date() } + const { username: rawUsername } = identity.traits return { id: identity.id as UserId, phone: identity.traits.phone as PhoneNumber, email: identity.traits.email as EmailAddress, + username: rawUsername !== undefined ? (rawUsername as IdentityUsername) : rawUsername, emailVerified: identity.verifiable_addresses?.[0].verified ?? false, totpEnabled: identity?.credentials?.totp?.type === "totp", schema: toSchema(identity.schema_id), @@ -98,10 +100,12 @@ export const toDomainIdentityEmail = (identity: KratosIdentity): IdentityEmail = createdAt = new Date() } + const { username: rawUsername } = identity.traits return { id: identity.id as UserId, phone: undefined, email: identity.traits.email as EmailAddress, + username: rawUsername !== undefined ? (rawUsername as IdentityUsername) : rawUsername, emailVerified: identity.verifiable_addresses?.[0].verified ?? false, totpEnabled: identity?.credentials?.totp?.type === "totp", schema: toSchema(identity.schema_id), @@ -123,10 +127,12 @@ export const toDomainIdentityPhone = (identity: KratosIdentity): IdentityPhone = createdAt = new Date() } + const { username: rawUsername } = identity.traits return { id: identity.id as UserId, phone: identity.traits.phone as PhoneNumber, email: undefined, + username: rawUsername !== undefined ? (rawUsername as IdentityUsername) : rawUsername, emailVerified: undefined, totpEnabled: identity?.credentials?.totp?.type === "totp", schema: toSchema(identity.schema_id), @@ -150,10 +156,12 @@ export const toDomainIdentityEmailPhone = ( createdAt = new Date() } + const { username: rawUsername } = identity.traits return { id: identity.id as UserId, phone: identity.traits.phone as PhoneNumber, email: identity.traits.email as EmailAddress, + username: rawUsername !== undefined ? (rawUsername as IdentityUsername) : rawUsername, emailVerified: identity.verifiable_addresses?.[0].verified ?? false, totpEnabled: identity?.credentials?.totp?.type === "totp", schema: toSchema(identity.schema_id), diff --git a/core/api/test/e2e/jest.config.js b/core/api/test/e2e/jest.config.js deleted file mode 100644 index 86cb35efec..0000000000 --- a/core/api/test/e2e/jest.config.js +++ /dev/null @@ -1,27 +0,0 @@ -const swcConfig = require("../swc-config.json") - -module.exports = { - moduleFileExtensions: ["js", "json", "ts", "gql", "cjs", "mjs"], - rootDir: "../../", - roots: ["/test/e2e"], - transform: { - "^.+\\.(t|j)sx?$": ["@swc/jest", swcConfig], - "^.+\\.(gql)$": "@graphql-tools/jest-transform", - }, - testRegex: ".*\\.spec\\.ts$", - setupFilesAfterEnv: ["/test/e2e/jest.setup.js"], - testEnvironment: "node", - moduleNameMapper: { - "^@config$": ["src/config/index"], - "^@app$": ["src/app/index"], - "^@utils$": ["src/utils/index"], - - "^@core/(.*)$": ["src/core/$1"], - "^@app/(.*)$": ["src/app/$1"], - "^@domain/(.*)$": ["src/domain/$1"], - "^@services/(.*)$": ["src/services/$1"], - "^@servers/(.*)$": ["src/servers/$1"], - "^@graphql/(.*)$": ["src/graphql/$1"], - "^test/(.*)$": ["test/$1"], - }, -} diff --git a/core/api/test/e2e/jest.setup.js b/core/api/test/e2e/jest.setup.js deleted file mode 100644 index 04ccaf3fe7..0000000000 --- a/core/api/test/e2e/jest.setup.js +++ /dev/null @@ -1,26 +0,0 @@ -const { disconnectAll } = require("@/services/redis") -const { setupMongoConnection } = require("@/services/mongodb") - -jest.mock("@/services/lnd/auth", () => { - const module = jest.requireActual("@/services/lnd/auth") - const lndsConnect = module.lndsConnect.map((p) => ({ ...p, active: true })) - return { ...module, lndsConnect } -}) - -let mongoose - -jest.mock("@/services/twilio-service", () => require("test/mocks/twilio")) - -beforeAll(async () => { - mongoose = await setupMongoConnection(true) -}) - -afterAll(async () => { - // avoids to use --forceExit - disconnectAll() - if (mongoose) { - await mongoose.connection.close() - } -}) - -jest.setTimeout(process.env.JEST_TIMEOUT || 90000) diff --git a/core/api/test/e2e/servers/index.types.d.ts b/core/api/test/e2e/servers/index.types.d.ts deleted file mode 100644 index 6968e44d92..0000000000 --- a/core/api/test/e2e/servers/index.types.d.ts +++ /dev/null @@ -1 +0,0 @@ -type PID = number & { readonly brand: unique symbol } diff --git a/core/api/test/e2e/servers/kratos.spec.ts b/core/api/test/e2e/servers/kratos.spec.ts deleted file mode 100644 index f786e7f9dd..0000000000 --- a/core/api/test/e2e/servers/kratos.spec.ts +++ /dev/null @@ -1,594 +0,0 @@ -import { authenticator } from "otplib" - -import { - AuthenticationError, - EmailCodeInvalidError, - LikelyNoUserWithThisPhoneExistError, - LikelyUserAlreadyExistError, -} from "@/domain/authentication/errors" -import { - AuthWithPhonePasswordlessService, - AuthWithUsernamePasswordDeviceIdService, - IdentityRepository, - extendSession, - getNextPageToken, - listSessions, - validateKratosToken, - AuthenticationKratosError, - IncompatibleSchemaUpgradeError, - KratosError, - AuthWithEmailPasswordlessService, - kratosValidateTotp, - kratosInitiateTotp, - kratosElevatingSessionWithTotp, - SchemaIdType, - kratosRemoveTotp, -} from "@/services/kratos" -import { kratosAdmin, kratosPublic } from "@/services/kratos/private" -import { - activateUser, - deactivateUser, - revokeSessions, -} from "@/services/kratos/tests-but-not-prod" - -import { sleep } from "@/utils" - -import { getError, randomEmail, randomPhone } from "test/helpers" - -import { getEmailCode } from "test/helpers/kratos" - -beforeAll(async () => { - // await removeIdentities() - // needed for the kratos callback to registration - // serverPid = await startServer("start-main-ci") -}) - -afterAll(async () => { - // await killServer(serverPid) -}) - -describe("phoneNoPassword", () => { - const authService = AuthWithPhonePasswordlessService() - - describe("public selflogin api", () => { - const phone = randomPhone() - let kratosUserId: UserId - - it("create a user", async () => { - const res = await authService.createIdentityWithSession({ phone }) - if (res instanceof Error) throw res - - expect(res).toHaveProperty("kratosUserId") - kratosUserId = res.kratosUserId - }) - - it("can't create user twice", async () => { - const res = await authService.createIdentityWithSession({ phone }) - - expect(res).toBeInstanceOf(LikelyUserAlreadyExistError) - }) - - it("login user succeed if user exists", async () => { - const res = await authService.loginToken({ phone }) - if (res instanceof Error) throw res - - expect(res.kratosUserId).toBe(kratosUserId) - }) - - it("get user id through getUserIdFromIdentifier(phone)", async () => { - const identities = IdentityRepository() - const userId = await identities.getUserIdFromIdentifier(phone) - - if (userId instanceof Error) throw userId - - expect(userId).toBe(kratosUserId) - }) - - it("new sessions are added when LoginWithPhoneNoPasswordSchema is used", async () => { - const res = await authService.loginToken({ phone }) - if (res instanceof Error) throw res - - expect(res.kratosUserId).toBe(kratosUserId) - const sessions = await listSessions(kratosUserId) - if (sessions instanceof Error) throw sessions - - expect(sessions).toHaveLength(3) - }) - - it("add totp", async () => { - const phone = randomPhone() - let authToken: AuthToken - let userId: UserId - - let totpSecret: string - { - const res0 = await authService.createIdentityWithSession({ phone }) - if (res0 instanceof Error) throw res0 - - authToken = res0.authToken - - const res1 = await kratosInitiateTotp(authToken) - if (res1 instanceof Error) throw res1 - - const { totpSecret: totpSecret_, totpRegistrationId } = res1 - totpSecret = totpSecret_ - const totpCode = authenticator.generate(totpSecret) - - const res = kratosValidateTotp({ totpRegistrationId, totpCode, authToken }) - if (res instanceof Error) throw res - - const res2 = await validateKratosToken(authToken) - if (res2 instanceof Error) throw res2 - expect(res2).toEqual( - expect.objectContaining({ - kratosUserId: expect.any(String), - session: expect.any(Object), - }), - ) - - // wait for the identity to be updated? - // some cache or asynchronous method need to run on the kratos side? - await sleep(100) - const identity = await IdentityRepository().getIdentity(res2.kratosUserId) - if (identity instanceof Error) throw identity - expect(identity.totpEnabled).toBe(true) - - userId = res2.kratosUserId - } - - { - const res = await authService.loginToken({ phone }) - if (res instanceof Error) throw res - expect(res).toEqual( - expect.objectContaining({ - kratosUserId: undefined, - authToken: expect.any(String), - }), - ) - - const totpCode = authenticator.generate(totpSecret) as TotpCode - - const res2 = await kratosElevatingSessionWithTotp({ - authToken: res.authToken, - totpCode, - }) - if (res2 instanceof Error) throw res2 - expect(res2).toBe(true) - } - - await kratosRemoveTotp(userId) - - // wait for the identity to be updated? - // some cache or asynchronous method need to run on the kratos side? - await sleep(100) - const identity = await IdentityRepository().getIdentity(userId) - if (identity instanceof Error) throw identity - expect(identity.totpEnabled).toBe(false) - }) - - it("login fails is user doesn't exist", async () => { - const phone = randomPhone() - const res = await authService.loginToken({ phone }) - expect(res).toBeInstanceOf(LikelyNoUserWithThisPhoneExistError) - }) - - it("forbidding change of a phone number from publicApi", async () => { - const phone = randomPhone() - - const res = await authService.createIdentityWithSession({ phone }) - if (res instanceof Error) throw res - - const res1 = await validateKratosToken(res.authToken) - if (res1 instanceof Error) throw res1 - expect(res1.session.identity.phone).toStrictEqual(phone) - - const res2 = await kratosPublic.createNativeSettingsFlow({ - xSessionToken: res.authToken, - }) - - const newPhone = randomPhone() - - const err = await getError(() => - kratosPublic.updateSettingsFlow({ - flow: res2.data.id, - updateSettingsFlowBody: { - method: "profile", - traits: { - phone: newPhone, - }, - }, - xSessionToken: res.authToken, - }), - ) - - expect(err).toBeTruthy() - }) - }) - - describe("admin api", () => { - it("create a user with admin api, and can login with self api", async () => { - const phone = randomPhone() - const kratosUserId = await authService.createIdentityNoSession({ phone }) - if (kratosUserId instanceof Error) throw kratosUserId - - const res2 = await authService.loginToken({ phone }) - if (res2 instanceof Error) throw res2 - - expect(res2.kratosUserId).toBe(kratosUserId) - }) - }) -}) - -describe("token validation", () => { - const authService = AuthWithPhonePasswordlessService() - - it("validate bearer token", async () => { - const phone = randomPhone() - const res = await authService.createIdentityWithSession({ phone }) - if (res instanceof Error) throw res - - const token = res.authToken - const res2 = await validateKratosToken(token) - if (res2 instanceof Error) throw res2 - expect(res2.kratosUserId).toBe(res.kratosUserId) - }) - - it("return error on invalid token", async () => { - const res = await validateKratosToken("invalid_token" as AuthToken) - expect(res).toBeInstanceOf(AuthenticationKratosError) - }) -}) - -describe("session revokation", () => { - const authService = AuthWithPhonePasswordlessService() - - const phone = randomPhone() - it("revoke user session", async () => { - const res = await authService.createIdentityWithSession({ phone }) - if (res instanceof Error) throw res - const kratosUserId = res.kratosUserId - - { - const { data } = await kratosAdmin.listIdentitySessions({ id: kratosUserId }) - expect(data.length).toBeGreaterThan(0) - } - - await revokeSessions(kratosUserId) - - { - const { data } = await kratosAdmin.listIdentitySessions({ id: kratosUserId }) - expect(data.length).toEqual(0) - } - }) - - it("return error on revoked session", async () => { - let token: AuthToken - { - const res = await authService.loginToken({ phone }) - if (res instanceof Error) throw res - if (res.kratosUserId === undefined) throw new Error("kratosUserId is undefined") - - token = res.authToken - await revokeSessions(res.kratosUserId) - } - { - const res = await validateKratosToken(token) - expect(res).toBeInstanceOf(AuthenticationKratosError) - } - }) - - it("revoke a user's second session only", async () => { - // Session 1 - const session1 = await authService.loginToken({ phone }) - if (session1 instanceof Error) throw session1 - const session1Token = session1.authToken - - // Session 2 - const session2 = await authService.loginToken({ phone }) - if (session2 instanceof Error) throw session2 - const session2Token = session2.authToken - - // Session Details - // *caveat, you need to have at least 2 active sessions - // for 'listMySessions' to work properly if you only - // have 1 active session the data will come back null - const session1Details = await kratosPublic.listMySessions({ - xSessionToken: session1Token, - }) - const session1Id = session1Details.data[0].id - const session2Details = await kratosPublic.listMySessions({ - xSessionToken: session2Token, - }) - const session2Id = session2Details.data[0].id - expect(session1Id).toBeDefined() - expect(session2Id).toBeDefined() - - // Revoke Session 2 - await kratosPublic.performNativeLogout({ - performNativeLogoutBody: { - session_token: session2Token, - }, - }) - - const isSession1Valid = await validateKratosToken(session1Token) - const isSession2Valid = await validateKratosToken(session2Token) - expect(isSession1Valid).toBeDefined() - expect(isSession2Valid).toBeInstanceOf(KratosError) - }) -}) - -describe.skip("update status", () => { - // Status on kratos is not implemented - const authService = AuthWithPhonePasswordlessService() - - let kratosUserId: UserId - const phone = randomPhone() - - it("deactivate user", async () => { - { - const res = await authService.createIdentityWithSession({ phone }) - if (res instanceof Error) throw res - kratosUserId = res.kratosUserId - } - await deactivateUser(kratosUserId) - await authService.loginToken({ phone }) - - const res = await authService.loginToken({ phone }) - expect(res).toBeInstanceOf(AuthenticationKratosError) - }) - - it("activate user", async () => { - await activateUser(kratosUserId) - const res = await authService.loginToken({ phone }) - if (res instanceof Error) throw res - expect(res.kratosUserId).toBe(kratosUserId) - }) -}) - -it("extend session", async () => { - const authService = AuthWithPhonePasswordlessService() - - const phone = randomPhone() - const res = await authService.createIdentityWithSession({ phone }) - if (res instanceof Error) throw res - - expect(res).toHaveProperty("kratosUserId") - const res2 = await kratosPublic.toSession({ xSessionToken: res.authToken }) - const sessionKratos = res2.data - if (!sessionKratos.expires_at) throw Error("should have expired_at") - const initialExpiresAt = new Date(sessionKratos.expires_at) - - const sessionId = sessionKratos.id as SessionId - - await extendSession(sessionId) - await sleep(200) - const res3 = await kratosPublic.toSession({ xSessionToken: res.authToken }) - const newSession = res3.data - if (!newSession.expires_at) throw Error("should have expired_at") - const newExpiresAt = new Date(newSession.expires_at) - - expect(initialExpiresAt.getTime()).toBeLessThan(newExpiresAt.getTime()) -}) - -describe("phone+email schema", () => { - const authServiceEmail = AuthWithEmailPasswordlessService() - const authServicePhone = AuthWithPhonePasswordlessService() - - let kratosUserId: UserId - const email = randomEmail() - const phone = randomPhone() - - it("create a user with phone", async () => { - const res0 = await authServicePhone.createIdentityWithSession({ phone }) - if (res0 instanceof Error) throw res0 - kratosUserId = res0.kratosUserId - - const newIdentity = await kratosAdmin.getIdentity({ id: kratosUserId }) - expect(newIdentity.data.traits.phone).toBe(phone) - - expect(await authServiceEmail.hasEmail({ kratosUserId })).toBe(false) - }) - - it("upgrade to phone+email schema", async () => { - const res = await authServiceEmail.addUnverifiedEmailToIdentity({ - kratosUserId, - email, - }) - if (res instanceof Error) throw res - - const newIdentity = await kratosAdmin.getIdentity({ id: kratosUserId }) - expect(newIdentity.data.schema_id).toBe("phone_email_no_password_v0") - expect(newIdentity.data.traits.email).toBe(email) - - expect(await authServiceEmail.hasEmail({ kratosUserId })).toBe(true) - expect(await authServiceEmail.isEmailVerified({ email })).toBe(false) - }) - - it("get user id through getUserIdFromIdentifier(email)", async () => { - const identities = IdentityRepository() - const userId = await identities.getUserIdFromIdentifier(email) - - if (userId instanceof Error) throw userId - expect(userId).toBe(kratosUserId) - }) - - it("can't add same email to multiple identities", async () => { - const phone = randomPhone() - const res0 = await authServicePhone.createIdentityWithSession({ phone }) - if (res0 instanceof Error) throw res0 - const kratosUserId = res0.kratosUserId - - const res = await authServiceEmail.addUnverifiedEmailToIdentity({ - kratosUserId, - email, - }) - if (!(res instanceof AuthenticationError)) throw new Error("wrong type") - expect(res.name).toBe("EmailAlreadyExistsError") - }) - - it("email verification", async () => { - const emailFlowId = await authServiceEmail.sendEmailWithCode({ email }) - if (emailFlowId instanceof Error) throw emailFlowId - - { - // TODO: look if there are rate limit on the side of kratos - const wrongCode = "000000" as EmailCode - const res = await authServiceEmail.validateCode({ - code: wrongCode, - emailFlowId, - }) - expect(res).toBeInstanceOf(EmailCodeInvalidError) - - expect(await authServiceEmail.isEmailVerified({ email })).toBe(false) - } - - { - const code = await getEmailCode(email) - - const res = await authServiceEmail.validateCode({ - code, - emailFlowId, - }) - if (res instanceof Error) throw res - expect(res.email).toBe(email) - - expect(await authServiceEmail.isEmailVerified({ email })).toBe(true) - } - }) - - it("login back to an email account", async () => { - const emailFlowId = await authServiceEmail.sendEmailWithCode({ email }) - if (emailFlowId instanceof Error) throw emailFlowId - - const code = await getEmailCode(email) - - { - const wrongCode = "000000" as EmailCode - const res = await authServiceEmail.validateCode({ - code: wrongCode, - emailFlowId: emailFlowId, - }) - expect(res).toBeInstanceOf(EmailCodeInvalidError) - } - - { - const res = await authServiceEmail.validateCode({ - code, - emailFlowId: emailFlowId, - }) - if (res instanceof Error) throw res - expect(res.email).toBe(email) - } - - { - const res = await authServiceEmail.loginToken({ email }) - if (res instanceof Error) throw res - expect(res.kratosUserId).toBe(kratosUserId) - } - }) - - // TODO: verification code expired - - it("login back to an phone+email account by phone", async () => { - const res = await authServicePhone.loginToken({ phone }) - if (res instanceof Error) throw res - - expect(res.kratosUserId).toBe(kratosUserId) - const identity = await kratosAdmin.getIdentity({ id: kratosUserId }) - expect(identity.data.schema_id).toBe("phone_email_no_password_v0") - }) - - it("remove email", async () => { - const res = await authServiceEmail.removeEmailFromIdentity({ kratosUserId }) - if (res instanceof Error) throw res - - const identity = await kratosAdmin.getIdentity({ id: kratosUserId }) - expect(identity.data.schema_id).toBe(SchemaIdType.PhoneNoPasswordV0) - expect(identity.data.traits.email).toBe(undefined) - }) - - it("can't remove phone if there is no email attached", async () => { - const res = await authServiceEmail.removePhoneFromIdentity({ kratosUserId }) - expect(res).toBeInstanceOf(IncompatibleSchemaUpgradeError) - }) - - it("remove phone from identity", async () => { - await authServiceEmail.addUnverifiedEmailToIdentity({ - kratosUserId, - email, - }) - - const emailRegistrationId = await authServiceEmail.sendEmailWithCode({ email }) - if (emailRegistrationId instanceof Error) throw emailRegistrationId - - { - const code = await getEmailCode(email) - await authServiceEmail.validateCode({ code, emailFlowId: emailRegistrationId }) - } - - await authServiceEmail.removePhoneFromIdentity({ kratosUserId }) - - const identity = await kratosAdmin.getIdentity({ id: kratosUserId }) - expect(identity.data.schema_id).toBe("email_no_password_v0") - }) - - it("verification on an inexistent email address result in not send an email", async () => { - const email = randomEmail() - - const flow = await authServiceEmail.sendEmailWithCode({ email }) - if (flow instanceof Error) throw flow - - // there is no email - await expect(async () => getEmailCode(email)).rejects.toThrow() - }) -}) - -describe("decoding link header", () => { - const withNext = - '; rel="first",; rel="next"' - - const withoutNext = - '; rel="first"' - - it("try decoding link successfully", () => { - expect(getNextPageToken(withNext)).toBe("h9LfEKUiFoLH2R0A") - }) - - it("should be undefined when no more next is present", () => { - expect(getNextPageToken(withoutNext)).toBe(undefined) - }) -}) - -describe("device account flow", () => { - const authService = AuthWithUsernamePasswordDeviceIdService() - const username = crypto.randomUUID() as IdentityUsername - const password = crypto.randomUUID() as IdentityPassword - let kratosUserId: UserId - - it("create an account", async () => { - const res = await authService.createIdentityWithSession({ - username, - password, - }) - if (res instanceof Error) throw res - ;({ kratosUserId } = res) - - const newIdentity = await kratosAdmin.getIdentity({ id: kratosUserId }) - expect(newIdentity.data.schema_id).toBe("username_password_deviceid_v0") - expect(newIdentity.data.traits.username).toBe(username) - }) - - it("upgrade account", async () => { - const phone = randomPhone() - - const authService = AuthWithPhonePasswordlessService() - const res = await authService.updateIdentityFromDeviceAccount({ - phone, - userId: kratosUserId, - }) - if (res instanceof Error) throw res - - expect(res.phone).toBe(phone) - expect(res.id).toBe(kratosUserId) - }) -}) diff --git a/core/api/test/helpers/bitcoin-core.ts b/core/api/test/helpers/bitcoin-core.ts index 23bb80bad5..169ab97d0b 100644 --- a/core/api/test/helpers/bitcoin-core.ts +++ b/core/api/test/helpers/bitcoin-core.ts @@ -1,5 +1,3 @@ -import { authenticatedBitcoind, createWallet, importDescriptors } from "bitcoin-cli-ts" - import { BitcoindClient, bitcoindDefaultClient, @@ -8,8 +6,6 @@ import { getBitcoinCoreSignerRPCConfig, } from "./bitcoind" -import { descriptors as signerDescriptors } from "./signer-wallet" - import { lndCreateOnChainAddress } from "./wallet" import { waitUntilBlockHeight } from "./lightning" @@ -132,41 +128,6 @@ export const fundWalletIdFromOnchain = async ({ return toSats(balance) } -export const createSignerWallet = async (walletName: string) => { - const bitcoindSigner = getBitcoindSignerClient() - const wallet = await createWallet({ - bitcoind: bitcoindSigner, - wallet_name: walletName, - disable_private_keys: false, - descriptors: true, - }) - - const bitcoindSignerWallet = getBitcoindSignerClient(walletName) - const result = await importDescriptors({ - bitcoind: bitcoindSignerWallet, - requests: signerDescriptors, - }) - - /* eslint @typescript-eslint/ban-ts-comment: "off" */ - // @ts-ignore-next-line no-implicit-any error - if (result.some((d) => !d.success)) throw new Error("Invalid descriptors") - - return wallet -} - -const getBitcoindSignerClient = (walletName?: string) => { - const { host, username, password, port, timeout } = getBitcoinCoreSignerRPCConfig() - return authenticatedBitcoind({ - protocol: "http", - host: host || "", - username, - password, - timeout, - port, - walletName, - }) -} - export const loadBitcoindWallet = async (walletName: string) => { const wallets = await bitcoindClient.listWallets() if (!wallets.includes(walletName)) { diff --git a/core/api/test/helpers/bitcoind.ts b/core/api/test/helpers/bitcoind.ts index a7e1296786..b3b9ffe97d 100644 --- a/core/api/test/helpers/bitcoind.ts +++ b/core/api/test/helpers/bitcoind.ts @@ -3,7 +3,6 @@ import { createWallet, generateToAddress, getAddressInfo, - getBlockchainInfo, getBlockCount, getNewAddress, getTransaction, @@ -89,10 +88,6 @@ export class BitcoindClient { return getBlockCount({ bitcoind: this.bitcoind }) } - async getBlockchainInfo(): Promise<{ chain: string }> { - return getBlockchainInfo({ bitcoind: this.bitcoind }) - } - async createWallet({ walletName, disablePrivateKeys, diff --git a/core/api/test/helpers/bria.ts b/core/api/test/helpers/bria.ts deleted file mode 100644 index 8fd8e90fad..0000000000 --- a/core/api/test/helpers/bria.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - RANDOM_ADDRESS, - bitcoindClient, - bitcoindOutside, - bitcoindSignerClient, - bitcoindSignerWallet, -} from "./bitcoin-core" - -import { waitFor, waitForNoErrorWithCount } from "./shared" - -import { BriaSubscriber, OnChainService } from "@/services/bria" -import { baseLogger } from "@/services/logger" - -export const getBriaBalance = async (): Promise => { - const service = OnChainService() - const hot = await service.getHotBalance() - if (hot instanceof Error) throw hot - // Cold wallet is not initialized in JS tests - // const cold = await service.getColdBalance() - // if (cold instanceof Error) throw cold - return Number(hot.amount) as Satoshis -} - -export const onceBriaSubscribe = async ({ - type, - txId, - payoutId, -}: { - type: BriaPayloadType - txId?: OnChainTxHash - payoutId?: PayoutId -}): Promise => { - const bria = BriaSubscriber() - - let eventToReturn: BriaEvent | undefined = undefined - - /* eslint @typescript-eslint/ban-ts-comment: "off" */ - // @ts-ignore-next-line no-implicit-any error - const eventHandler = ({ resolve, timeoutId }) => { - return async (event: BriaEvent): Promise => { - setTimeout(() => { - if ( - event.payload.type === type && - (!txId || ("txId" in event.payload && event.payload.txId === txId)) && - (!payoutId || ("id" in event.payload && event.payload.id === payoutId)) - ) { - eventToReturn = event - resolve(event) - clearTimeout(timeoutId) - } - }, 1) - return Promise.resolve(true) - } - } - - const timeout = 20_000 - let wrapper - const promise = new Promise(async (resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`Promise timed out after ${timeout} ms`)) - }, timeout) - wrapper = await bria.subscribeToAll(eventHandler({ resolve, timeoutId })) - }) - - const res = await promise - if (res instanceof Error) throw res - - // @ts-ignore-next-line no-implicit-any error - wrapper.cancel() - return eventToReturn -} - -export const manyBriaSubscribe = async ({ - type, - addresses, -}: { - type: BriaPayloadType - addresses: OnChainAddress[] -}): Promise => { - const bria = BriaSubscriber() - - const eventsToReturn: BriaEvent[] = [] - - // @ts-ignore-next-line no-implicit-any error - const eventHandler = ({ resolve, timeoutId }) => { - return async (event: BriaEvent): Promise => { - setTimeout(() => { - if ( - event.payload.type === type && - "address" in event.payload && - addresses.includes(event.payload.address) - ) { - eventsToReturn.push(event) - - if (eventsToReturn.length === addresses.length) { - resolve(eventsToReturn) - clearTimeout(timeoutId) - } - } - }, 1) - return Promise.resolve(true) - } - } - - const timeout = 20_000 - let wrapper - const promise = new Promise(async (resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`Promise timed out after ${timeout} ms`)) - }, timeout) - wrapper = await bria.subscribeToAll(eventHandler({ resolve, timeoutId })) - }) - - const res = await promise - if (res instanceof Error) throw res - - // @ts-ignore-next-line no-implicit-any error - wrapper.cancel() - return eventsToReturn -} - -export const resetBria = async () => { - const block = await bitcoindClient.getBlockCount() - if (!block) return // skip if we are just getting started - - const existingSignerWallets = await bitcoindSignerClient.listWalletDir() - if (!existingSignerWallets.map((wallet) => wallet.name).includes("dev")) { - return - } - - const balance = await bitcoindSignerWallet.getBalance() - if (balance === 0) return - - await bitcoindSignerWallet.sendToAddress({ - address: RANDOM_ADDRESS, - amount: balance, - subtractfeefromamount: true, - }) - await bitcoindOutside.generateToAddress({ nblocks: 3, address: RANDOM_ADDRESS }) - - await waitUntilBriaZeroBalance() -} - -const waitUntilBriaZeroBalance = async () => { - await waitFor(async () => { - const balanceAmount = await OnChainService().getHotBalance() - if (balanceAmount instanceof Error) throw balanceAmount - const balance = Number(balanceAmount.amount) - - if (balance > 0) { - baseLogger.warn({ briaBalance: `${balance} sats` }, "bria balance not zero yet") - return false - } - - return true - }) -} - -export const waitUntilBriaConnected = async () => { - // @ts-ignore-next-line no-implicit-any error - const balance = await waitForNoErrorWithCount(OnChainService().getHotBalance, 60) - if (balance instanceof Error) throw balance -} diff --git a/core/api/test/helpers/check-is-balanced.ts b/core/api/test/helpers/check-is-balanced.ts deleted file mode 100644 index 9fd7f44626..0000000000 --- a/core/api/test/helpers/check-is-balanced.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - waitUntilChannelBalanceSyncE2e, - waitUntilChannelBalanceSyncIntegration, -} from "./lightning" - -import { getBriaBalance } from "./bria" - -import { updatePendingPayments } from "@/app/payments" -import { handleHeldInvoices, updateLegacyOnChainReceipt } from "@/app/wallets" -import { baseLogger } from "@/services/logger" - -import { ledgerAdmin } from "@/services/mongodb" -import { lndsBalances } from "@/services/lnd/utils" - -const logger = baseLogger.child({ module: "test" }) - -export const checkIsBalanced = async () => { - await Promise.all([ - handleHeldInvoices(logger), - updatePendingPayments(logger), - updateLegacyOnChainReceipt({ logger }), - ]) - // wait for balance updates because invoice event - // arrives before wallet balances updates in lnd - await waitUntilChannelBalanceSyncIntegration() - - const { assetsLiabilitiesDifference, bookingVersusRealWorldAssets } = - await balanceSheetIsBalanced() - expect(assetsLiabilitiesDifference).toBe(0) - - // TODO: need to go from sats to msats to properly account for every msats spent - expect(Math.abs(bookingVersusRealWorldAssets)).toBe(0) -} - -export const checkIsBalancedE2e = async () => { - await Promise.all([ - handleHeldInvoices(logger), - updatePendingPayments(logger), - updateLegacyOnChainReceipt({ logger }), - ]) - // wait for balance updates because invoice event - // arrives before wallet balances updates in lnd - await waitUntilChannelBalanceSyncE2e() - - const { assetsLiabilitiesDifference, bookingVersusRealWorldAssets } = - await balanceSheetIsBalanced() - expect(assetsLiabilitiesDifference).toBe(0) - - // TODO: need to go from sats to msats to properly account for every msats spent - expect(Math.abs(bookingVersusRealWorldAssets)).toBe(0) -} - -const getLedgerAccounts = async () => { - const [assets, liabilities, lightning, bitcoin, bankOwnerBalance, onChain] = - await Promise.all([ - ledgerAdmin.getAssetsBalance(), - ledgerAdmin.getLiabilitiesBalance(), - ledgerAdmin.getLndBalance(), - ledgerAdmin.getBitcoindBalance(), - ledgerAdmin.getBankOwnerBalance(), - ledgerAdmin.getOnChainBalance(), - ]) - - return { assets, liabilities, lightning, bitcoin, bankOwnerBalance, onChain } -} - -const balanceSheetIsBalanced = async () => { - const { assets, liabilities, lightning, bitcoin, bankOwnerBalance, onChain } = - await getLedgerAccounts() - const { total: lnd } = await lndsBalances() // doesnt include escrow amount - - const bria = await getBriaBalance() - - const assetsLiabilitiesDifference = - assets /* assets is ___ */ + liabilities /* liabilities is ___ */ - - const bookingVersusRealWorldAssets = - lnd + // physical assets - bria + // physical assets - (lightning + bitcoin + onChain) // value in accounting - - if (!!bookingVersusRealWorldAssets || !!assetsLiabilitiesDifference) { - logger.warn( - { - assetsLiabilitiesDifference, - bookingVersusRealWorldAssets, - assets, - liabilities, - bankOwnerBalance, - lnd, - lightning, - bitcoin, - bria, - onChain, - }, - `not balanced`, - ) - } - - return { assetsLiabilitiesDifference, bookingVersusRealWorldAssets } -} diff --git a/core/api/test/helpers/index.ts b/core/api/test/helpers/index.ts index cd2bf8d45c..2606f93bc1 100644 --- a/core/api/test/helpers/index.ts +++ b/core/api/test/helpers/index.ts @@ -1,108 +1,10 @@ -import { ExecutionResult, graphql, Source } from "graphql" - -import { ObjMap } from "graphql/jsutils/ObjMap" - -import { randomUserId } from "./random" - -import { gqlAdminSchema } from "@/graphql/admin" - -import { AccountsRepository } from "@/services/mongoose" -import { getCurrencyMajorExponent, priceAmountFromNumber } from "@/domain/fiat" -import { DepositFeeCalculator } from "@/domain/wallets" -import { AmountCalculator } from "@/domain/shared" - export * from "./bitcoin-core" -export * from "./bria" -export * from "./check-is-balanced" export * from "./get-error" export * from "./generate-hash" export * from "./ledger" export * from "./lightning" export * from "./price" export * from "./random" -export * from "./rate-limit" -export * from "./redis" export * from "./shared" export * from "./user" export * from "./wallet" - -const calc = AmountCalculator() - -// TODO: use same function as createUserAndWallet -export const randomAccount = async () => { - const account = await AccountsRepository().persistNew(randomUserId()) - if (account instanceof Error) throw account - return account -} - -export const amountAfterFeeDeduction = ({ - amount, - minBankFee, - minBankFeeThreshold, - depositFeeRatio, -}: { - amount: BtcPaymentAmount - minBankFee: BtcPaymentAmount - minBankFeeThreshold: BtcPaymentAmount - depositFeeRatio: DepositFeeRatioAsBasisPoints -}) => { - const satsFee = DepositFeeCalculator().onChainDepositFee({ - amount, - minBankFee, - minBankFeeThreshold, - ratio: depositFeeRatio, - }) - if (satsFee instanceof Error) throw satsFee - - return Number(calc.sub(amount, satsFee).amount) -} - -export const resetDatabase = async (mongoose: typeof import("mongoose")) => { - const db = mongoose.connection.db - // Get all collections - const collections = await db.listCollections().toArray() - // Create an array of collection names and drop each collection - const collectionNames = collections.map((c) => c.name) - for (const collectionName of collectionNames) { - await db.dropCollection(collectionName) - } -} - -export const amountByPriceAsMajor = < - S extends WalletCurrency, - T extends DisplayCurrency, ->({ - amount, - price, - walletCurrency, - displayCurrency, -}: { - amount: Satoshis | UsdCents - price: WalletMinorUnitDisplayPrice | undefined - walletCurrency: S - displayCurrency: T -}): number => { - const priceAmount = - price === undefined - ? priceAmountFromNumber({ - priceOfOneSatInMinorUnit: 0, - displayCurrency, - walletCurrency, - }) - : price - - const exponent = getCurrencyMajorExponent(displayCurrency) - return ( - (amount * Number(priceAmount.base)) / 10 ** (Number(priceAmount.offset) + exponent) - ) -} - -export const graphqlAdmin = async < - T = Promise, ObjMap>>, ->({ - source, - contextValue, -}: { - source: string | Source - contextValue?: Partial -}) => graphql({ schema: gqlAdminSchema, source, contextValue }) as unknown as T diff --git a/core/api/test/helpers/kratos.ts b/core/api/test/helpers/kratos.ts index 5147e04e3c..bf5e2fa388 100644 --- a/core/api/test/helpers/kratos.ts +++ b/core/api/test/helpers/kratos.ts @@ -30,31 +30,3 @@ export const getEmailCode = async (email: EmailAddress) => { const code = message.body.split("code:\n\n")[1].slice(0, 6) return code } - -export const getEmailCount = async (email: EmailAddress) => { - const knex = getKratosKnex() - - const table = "courier_messages" - - // make the query - const res = await knex.select(["recipient", "body", "created_at"]).from(table) - - await knex.destroy() - - const count = res.filter((item) => item.recipient === email).length - - return count -} - -export const removeIdentities = async () => { - const knex = getKratosKnex() - - const table = "identities" - - // truncate the table - const resTruncate = await getKratosKnex()(table).truncate() - - knex.destroy() - - console.log({ resTruncate }) -} diff --git a/core/api/test/helpers/ledger.ts b/core/api/test/helpers/ledger.ts index 505ffce7fe..31b8f7603f 100644 --- a/core/api/test/helpers/ledger.ts +++ b/core/api/test/helpers/ledger.ts @@ -1,14 +1,11 @@ import crypto from "crypto" -import mongoose from "mongoose" - import { generateHash } from "./generate-hash" import { WalletCurrency, ZERO_CENTS, ZERO_SATS } from "@/domain/shared" import { toSats } from "@/domain/bitcoin" import { LedgerTransactionType, toLiabilitiesWalletId } from "@/domain/ledger" -import { toObjectId } from "@/services/mongoose/utils" import { MainBook } from "@/services/ledger/books" import { translateToLedgerJournal } from "@/services/ledger" import { getBankOwnerWalletId } from "@/services/ledger/caching" @@ -19,73 +16,6 @@ import { } from "@/services/ledger/domain" import * as LedgerFacade from "@/services/ledger/facade" -const Journal = mongoose.models.Medici_Journal -const Transaction = mongoose.models.Medici_Transaction - -export const markFailedTransactionAsPending = async (id: LedgerJournalId) => { - const journalIdAsObject = toObjectId(id) - - // Step 1: Fetch transaction and confirm voided - // === - const { results: journalTxns } = await MainBook.ledger({ - _journal: journalIdAsObject, - }) - if (!(journalTxns && journalTxns.length > 0)) { - throw new Error("No transactions found for journalId") - } - expect(journalTxns[0]).toHaveProperty("voided") - expect(journalTxns[0]).toHaveProperty("void_reason") - - // Step 2: Fetch canceling transactions by "original_journal" - // === - const { results: cancelJournalTxns } = await MainBook.ledger({ - _original_journal: journalIdAsObject, - }) - if (!(cancelJournalTxns && cancelJournalTxns.length > 0)) { - throw new Error("No canceled counter-transactions found") - } - const voidJournalIdAsObject = cancelJournalTxns[0]._journal - - // Step 3: Get original journal Id and delete journal + entries - // === - await Transaction.deleteMany({ - _journal: voidJournalIdAsObject, - }) - - await Journal.deleteOne({ - _id: voidJournalIdAsObject, - }) - - // Step 4: Remove voided status from original txns/journal, and mark original txns as pending - // === - await Transaction.updateMany( - { _journal: journalIdAsObject }, - { pending: true, $unset: { voided: 1, void_reason: 1 } }, - ) - - await Journal.updateMany( - { _id: journalIdAsObject }, - { $unset: { voided: 1, void_reason: 1 } }, - ) -} - -export const markSuccessfulTransactionAsPending = async (id: LedgerJournalId) => { - const journalIdAsObject = toObjectId(id) - - // Step 1: Fetch transaction and confirm not voided - const { results: journalTxns } = await MainBook.ledger({ - _journal: journalIdAsObject, - }) - if (!(journalTxns && journalTxns.length > 0)) { - throw new Error("No transactions found for journalId") - } - expect(journalTxns[0]).not.toHaveProperty("voided") - expect(journalTxns[0]).not.toHaveProperty("void_reason") - - // Step 2: Mark original txns as pending - await Transaction.updateMany({ _journal: journalIdAsObject }, { pending: true }) -} - export const recordReceiveLnPayment = async ({ walletDescriptor, paymentAmount, diff --git a/core/api/test/helpers/lightning.ts b/core/api/test/helpers/lightning.ts index 514bdbca2e..ffc17b7911 100644 --- a/core/api/test/helpers/lightning.ts +++ b/core/api/test/helpers/lightning.ts @@ -5,10 +5,7 @@ import { closeChannel, createChainAddress, getChainBalance, - getChannelBalance, getChannels, - getInvoice, - getNetworkGraph, getWalletInfo, openChannel, pay, @@ -18,8 +15,6 @@ import { updateRoutingFees, } from "lightning" -import { parsePaymentRequest } from "invoices" - import { bitcoindClient, bitcoindOutside, @@ -42,42 +37,6 @@ export const lnd1 = offchainLnds[0].lnd export const lnd2 = offchainLnds[1].lnd export const lndonchain = onchainLnds[0].lnd -export const getHash = (request: EncodedPaymentRequest) => { - return parsePaymentRequest({ request }).id as PaymentHash -} - -export const getAmount = (request: EncodedPaymentRequest) => { - return parsePaymentRequest({ request }).tokens as Satoshis -} - -export const getPubKey = (request: EncodedPaymentRequest) => { - return parsePaymentRequest({ request }).destination as Pubkey -} - -export const getInvoiceAttempt = async ({ - lnd, - id, -}: { - lnd: AuthenticatedLnd - id: string -}) => { - try { - const result = await getInvoice({ lnd, id }) - return result - } catch (err) { - const invoiceNotFound = "unable to locate invoice" - if ( - Array.isArray(err) && - err.length === 3 && - err[2]?.err?.details === invoiceNotFound - ) { - return null - } - // must be wrapped error? - throw err - } -} - // TODO: this could be refactored with lndAuth export const lndOutside1 = authenticatedLndGrpc({ cert: process.env.TLSOUTSIDE1, @@ -93,7 +52,6 @@ export const lndOutside2 = authenticatedLndGrpc({ export const lndsIntegration = [lnd1, lndOutside1, lndOutside2] export const lndsLegacyIntegration = [lnd1, lnd2, lndOutside1, lndOutside2] -export const lndsE2e = [lnd1, lnd2, lndOutside1, lndOutside2] export const waitUntilBlockHeight = async ({ lnd, @@ -302,7 +260,6 @@ const resetLnds = async (lnds: AuthenticatedLnd[]) => { export const resetIntegrationLnds = () => resetLnds(lndsIntegration) export const resetLegacyIntegrationLnds = () => resetLnds(lndsLegacyIntegration) -export const resetE2eLnds = () => resetLnds(lndsE2e) export const closeAllChannels = async ({ lnd }: { lnd: AuthenticatedLnd }) => { let channels @@ -391,14 +348,6 @@ export const mineBlockAndSync = async ({ await Promise.all(promiseArray) } -export const mineBlockAndSyncAll = (newBlock = 6) => - mineBlockAndSync({ lnds: lndsIntegration, newBlock }) -export const mineBlockAndSyncAllE2e = (newBlock = 6) => - mineBlockAndSync({ lnds: lndsE2e, newBlock }) - -export const waitUntilSyncAll = () => waitUntilSync({ lnds: lndsIntegration }) -export const waitUntilSyncAllE2e = () => waitUntilSync({ lnds: lndsE2e }) - export const waitUntilSync = async ({ lnds }: { lnds: Array }) => { const promiseArray: Array> = [] for (const lnd of lnds) { @@ -408,50 +357,6 @@ export const waitUntilSync = async ({ lnds }: { lnds: Array }) await Promise.all(promiseArray) } -const waitUntilChannelBalanceSyncAll = async (lnds: AuthenticatedLnd[]) => { - const promiseArray: Array> = [] - for (const lnd of lnds) { - /* eslint @typescript-eslint/ban-ts-comment: "off" */ - // @ts-ignore-next-line no-implicit-any error - promiseArray.push(waitUntilChannelBalanceSync({ lnd })) - } - await Promise.all(promiseArray) -} -export const waitUntilChannelBalanceSyncIntegration = () => - waitUntilChannelBalanceSyncAll(lndsIntegration) - -export const waitUntilChannelBalanceSyncE2e = () => - waitUntilChannelBalanceSyncAll(lndsE2e) - -// @ts-ignore-next-line no-implicit-any error -export const waitUntilChannelBalanceSync = ({ lnd }) => - waitFor(async () => { - const { unsettled_balance } = await getChannelBalance({ lnd }) - return unsettled_balance === 0 - }) - -export const waitUntilGraphIsReady = async ({ - lnd, - numNodes = 4, -}: { - lnd: AuthenticatedLnd - numNodes: number -}) => { - await waitFor(async () => { - const graph = await getNetworkGraph({ lnd }) - if (graph.nodes.length < numNodes) { - baseLogger.warn({ nodeLength: graph.nodes.length }, "missing nodes in graph") - return false - } - if (graph.nodes.every((node) => node.updated_at === "")) { - const nodesUpdated = graph.nodes.filter((node) => node.updated_at === "").length - baseLogger.warn({ nodesUpdated }, "graph metadata not ready") - return false - } - return true - }) -} - // @ts-ignore-next-line no-implicit-any error export const safePay = async (args) => { try { @@ -462,12 +367,3 @@ export const safePay = async (args) => { expect(err).toBeUndefined() } } - -// @ts-ignore-next-line no-implicit-any error -export const safePayNoExpect = async (args) => { - try { - return await pay(args) // 'await' is explicitly needed here - } catch (err) { - return err - } -} diff --git a/core/api/test/helpers/random.ts b/core/api/test/helpers/random.ts index bf2a79390c..a1f0d712dc 100644 --- a/core/api/test/helpers/random.ts +++ b/core/api/test/helpers/random.ts @@ -10,6 +10,6 @@ export const randomPhone = () => export const randomUserId = () => randomUUID() as UserId -export const randomDeviceId = () => randomUUID() as DeviceId - export const randomWalletId = () => randomUUID() as WalletId + +export const randomUsername = () => randomUUID() as IdentityUsername diff --git a/core/api/test/helpers/rate-limit.ts b/core/api/test/helpers/rate-limit.ts deleted file mode 100644 index fa75df897d..0000000000 --- a/core/api/test/helpers/rate-limit.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { RateLimitConfig } from "@/domain/rate-limit" -import { resetLimiter } from "@/services/rate-limit" - -export const resetSelfAccountIdLimits = async ( - accountId: AccountId, -): Promise => - resetLimiter({ - rateLimitConfig: RateLimitConfig.invoiceCreate, - keyToConsume: accountId, - }) - -export const resetRecipientAccountIdLimits = async ( - accountId: AccountId, -): Promise => - resetLimiter({ - rateLimitConfig: RateLimitConfig.invoiceCreateForRecipient, - keyToConsume: accountId, - }) - -export const resetOnChainAddressAccountIdLimits = async ( - accountId: AccountId, -): Promise => - resetLimiter({ - rateLimitConfig: RateLimitConfig.onChainAddressCreate, - keyToConsume: accountId, - }) - -export const resetUserPhoneCodeAttemptPhone = async ( - phone: PhoneNumber, -): Promise => - resetLimiter({ - rateLimitConfig: RateLimitConfig.requestCodeAttemptPerLoginIdentifier, - keyToConsume: phone, - }) - -export const resetUserPhoneCodeAttemptIp = async ( - ip: IpAddress, -): Promise => - resetLimiter({ - rateLimitConfig: RateLimitConfig.requestCodeAttemptPerIp, - keyToConsume: ip, - }) - -export const resetUserLoginPhoneRateLimits = async ( - phone: PhoneNumber, -): Promise => - resetLimiter({ - rateLimitConfig: RateLimitConfig.loginAttemptPerLoginIdentifier, - keyToConsume: phone, - }) - -export const resetUserLoginIpRateLimits = async ( - ip: IpAddress, -): Promise => - resetLimiter({ - rateLimitConfig: RateLimitConfig.failedLoginAttemptPerIp, - keyToConsume: ip, - }) diff --git a/core/api/test/helpers/redis.ts b/core/api/test/helpers/redis.ts deleted file mode 100644 index fd9e83abec..0000000000 --- a/core/api/test/helpers/redis.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { RateLimitPrefix } from "@/domain/rate-limit" -import { redis } from "@/services/redis" - -export const clearKeys = async (prefix: string) => { - const keys = await redis.keys(`${prefix}:*`) - for (const key of keys) { - await redis.del(key) - } -} - -export const clearAccountLocks = () => clearKeys("locks:account") - -export const clearLimitersWithExclusions = async (exclusions: string[]) => { - for (const limiter in RateLimitPrefix) { - /* eslint @typescript-eslint/ban-ts-comment: "off" */ - // @ts-ignore-next-line no-implicit-any error - const limiterValue = RateLimitPrefix[limiter] - if (exclusions.includes(limiterValue)) continue - await clearKeys(limiterValue) - } -} - -export const clearLimiters = () => clearLimitersWithExclusions([]) diff --git a/core/api/test/helpers/shared.ts b/core/api/test/helpers/shared.ts index 9b33a65ed5..c117070a30 100644 --- a/core/api/test/helpers/shared.ts +++ b/core/api/test/helpers/shared.ts @@ -5,14 +5,3 @@ export const waitFor = async (f: () => Promise) => { while (!(res = await f())) await sleep(500) return res } - -export const waitForNoErrorWithCount = async (f: () => Promise, max: number) => { - let count = 0 - let res = new Error() - while (res instanceof Error && count < max) { - res = await f() - await sleep(500) - count++ - } - return res -} diff --git a/core/api/test/helpers/signer-wallet.ts b/core/api/test/helpers/signer-wallet.ts deleted file mode 100644 index be27f0b84c..0000000000 --- a/core/api/test/helpers/signer-wallet.ts +++ /dev/null @@ -1,13 +0,0 @@ -export const descriptors = [ - { - active: true, - desc: "wpkh([6f2fa1b2/84'/0'/0']tprv8gXB88g1VCScmqPp8WcetpJPRxix24fRJJ6FniYCcCUEFMREDrCfwd34zWXPiY5MW2xp8e1Z6EeBrh74zMSgfQQmTorWtE1zyBtv7yxdcoa/0/*)#88k4937c", - timestamp: 0, - }, - { - active: true, - desc: "wpkh([6f2fa1b2/84'/0'/0']tprv8gXB88g1VCScmqPp8WcetpJPRxix24fRJJ6FniYCcCUEFMREDrCfwd34zWXPiY5MW2xp8e1Z6EeBrh74zMSgfQQmTorWtE1zyBtv7yxdcoa/1/*)#knn5cywq", - internal: true, - timestamp: 0, - }, -] diff --git a/core/api/test/helpers/user.ts b/core/api/test/helpers/user.ts index 33f8daef53..af8847c0a0 100644 --- a/core/api/test/helpers/user.ts +++ b/core/api/test/helpers/user.ts @@ -7,7 +7,6 @@ import { getAdminAccounts, getDefaultAccountsConfig } from "@/config" import { CouldNotFindAccountFromKratosIdError, CouldNotFindError } from "@/domain/errors" import { WalletCurrency } from "@/domain/shared" -import { WalletType } from "@/domain/wallets" import { AccountsRepository, @@ -15,7 +14,6 @@ import { WalletsRepository, } from "@/services/mongoose" import { AccountsIpsRepository } from "@/services/mongoose/accounts-ips" -import { Account } from "@/services/mongoose/schema" import { baseLogger } from "@/services/logger" @@ -39,64 +37,11 @@ export const getUserIdByPhone = async (phone: PhoneNumber) => { return user.id } -export const getAccountIdByPhone = async (phone: PhoneNumber) => { - const account = await getAccountByPhone(phone) - return account.id -} - export const getDefaultWalletIdByPhone = async (ref: PhoneNumber) => { const account = await getAccountByPhone(ref) return account.defaultWalletId } -export const getBtcWalletDescriptorByPhone = async ( - ref: PhoneNumber, -): Promise> => { - const account = await getAccountByPhone(ref) - - const wallets = await WalletsRepository().listByAccountId(account.id) - if (wallets instanceof Error) throw wallets - - const wallet = wallets.find((w) => w.currency === WalletCurrency.Btc) - if (wallet === undefined) throw Error("no BTC wallet") - - return { id: wallet.id, currency: WalletCurrency.Btc, accountId: wallet.accountId } -} - -export const getUsdWalletDescriptorByPhone = async ( - ref: PhoneNumber, -): Promise> => { - const account = await getAccountByPhone(ref) - - const wallets = await WalletsRepository().listByAccountId(account.id) - if (wallets instanceof Error) throw wallets - - const wallet = wallets.find((w) => w.currency === WalletCurrency.Usd) - if (wallet === undefined) throw Error("no USD wallet") - - return { id: wallet.id, currency: WalletCurrency.Usd, accountId: wallet.accountId } -} - -export const getUsdWalletIdByPhone = async (phone: PhoneNumber) => { - const account = await getAccountByPhone(phone) - - const walletsRepo = WalletsRepository() - const wallets = await walletsRepo.listByAccountId(account.id) - if (wallets instanceof Error) throw wallets - - const wallet = wallets.find((w) => w.currency === WalletCurrency.Usd) - if (wallet === undefined) throw Error("no USD wallet") - return wallet.id -} - -export const getAccountRecordByPhone = async (phone: PhoneNumber) => { - const user = await UsersRepository().findByPhone(phone) - if (user instanceof Error) throw user - const accountRecord = await Account.findOne({ kratosUserId: user.id }) - if (!accountRecord) throw Error("missing account") - return accountRecord -} - export const createMandatoryUsers = async () => { const adminUsers = getAdminAccounts() @@ -258,23 +203,6 @@ export const createUserAndWallet = async ( } } -export const addNewWallet = async ({ - accountId, - currency, -}: { - accountId: AccountId - currency: WalletCurrency -}): Promise => { - const wallet = await WalletsRepository().persistNew({ - accountId, - type: WalletType.Checking, - currency, - }) - if (wallet instanceof Error) throw wallet - - return wallet -} - export const fundWallet = async ({ walletId, balanceAmount, diff --git a/core/api/test/helpers/wallet.ts b/core/api/test/helpers/wallet.ts index fb8fd434bd..21c7c3c9cb 100644 --- a/core/api/test/helpers/wallet.ts +++ b/core/api/test/helpers/wallet.ts @@ -1,12 +1,7 @@ import { createChainAddress } from "lightning" -import { - getBalanceForWallet, - getPendingIncomingOnChainTransactionsForWallets, - getTransactionsForWallets, -} from "@/app/wallets" -import { RepositoryError } from "@/domain/errors" -import { WalletsRepository, WalletOnChainAddressesRepository } from "@/services/mongoose" +import { getBalanceForWallet } from "@/app/wallets" +import { WalletOnChainAddressesRepository } from "@/services/mongoose" import { getActiveLnd } from "@/services/lnd/config" export const getBalanceHelper = async ( @@ -17,26 +12,6 @@ export const getBalanceHelper = async ( return balance } -export const getTransactionsForWalletId = async ( - walletId: WalletId, -): Promise | ApplicationError> => { - const wallets = WalletsRepository() - const wallet = await wallets.findById(walletId) - if (wallet instanceof RepositoryError) return wallet - return getTransactionsForWallets({ wallets: [wallet], rawPaginationArgs: {} }) -} - -export const getPendingTransactionsForWalletId = async ( - walletId: WalletId, -): Promise => { - const wallets = WalletsRepository() - const wallet = await wallets.findById(walletId) - if (wallet instanceof RepositoryError) return wallet - return getPendingIncomingOnChainTransactionsForWallets({ - wallets: [wallet], - }) -} - // This is to test detection of funds coming in on legacy addresses // via LND. Remove once all on chain wallets are migrated to Bria export const lndCreateOnChainAddress = async ( diff --git a/core/api/test/integration/services/auth-service.spec.ts b/core/api/test/integration/services/auth-service.spec.ts new file mode 100644 index 0000000000..f40970e012 --- /dev/null +++ b/core/api/test/integration/services/auth-service.spec.ts @@ -0,0 +1,587 @@ +import { authenticator } from "otplib" + +import { + EmailCodeInvalidError, + EmailUnverifiedError, + LikelyNoUserWithThisPhoneExistError, + LikelyUserAlreadyExistError, +} from "@/domain/authentication/errors" +import { + AuthWithEmailPasswordlessService, + AuthWithPhonePasswordlessService, + AuthWithUsernamePasswordDeviceIdService, + AuthenticationKratosError, + EmailAlreadyExistsError, + IdentityRepository, + IncompatibleSchemaUpgradeError, + KratosError, + SchemaIdType, + extendSession, + kratosElevatingSessionWithTotp, + kratosInitiateTotp, + kratosRemoveTotp, + kratosValidateTotp, + listSessions, + validateKratosToken, +} from "@/services/kratos" +import { kratosAdmin, kratosPublic } from "@/services/kratos/private" +import { + activateUser, + deactivateUser, + revokeSessions, +} from "@/services/kratos/tests-but-not-prod" +import { sleep } from "@/utils" + +import { + getError, + randomEmail, + randomPassword, + randomPhone, + randomUsername, +} from "test/helpers" +import { getEmailCode } from "test/helpers/kratos" + +const createIdentity = async () => { + const phone = randomPhone() + const created = await AuthWithPhonePasswordlessService().createIdentityWithSession({ + phone, + }) + if (created instanceof Error) throw created + return { phone, kratosUserId: created.kratosUserId, authToken: created.authToken } +} + +describe("phoneNoPassword schema", () => { + const authService = AuthWithPhonePasswordlessService() + const identities = IdentityRepository() + + describe("public selflogin api", () => { + describe("user", () => { + it("creates a user", async () => { + const phone = randomPhone() + const res = await authService.createIdentityWithSession({ phone }) + if (res instanceof Error) throw res + expect(res).toHaveProperty("kratosUserId") + + const resRetryCreate = await authService.createIdentityWithSession({ phone }) + expect(resRetryCreate).toBeInstanceOf(LikelyUserAlreadyExistError) + }) + + it("logs user in if exists", async () => { + const { phone, kratosUserId } = await createIdentity() + + const res = await authService.loginToken({ phone }) + if (res instanceof Error) throw res + expect(res.kratosUserId).toBe(kratosUserId) + + const identity = await identities.getIdentity(kratosUserId) + if (identity instanceof Error) throw identity + expect(identity.schema).toBe(SchemaIdType.PhoneNoPasswordV0) + }) + + it("fails to log user in if doesn't exist", async () => { + const phone = randomPhone() + const res = await authService.loginToken({ phone }) + expect(res).toBeInstanceOf(LikelyNoUserWithThisPhoneExistError) + }) + + it("validate bearer token", async () => { + const { kratosUserId, authToken } = await createIdentity() + + const res = await validateKratosToken(authToken) + if (res instanceof Error) throw res + expect(res.kratosUserId).toBe(kratosUserId) + }) + + it("return error on invalid token", async () => { + const res = await validateKratosToken("invalid_token" as AuthToken) + expect(res).toBeInstanceOf(AuthenticationKratosError) + }) + + it("adds totp (2FA) to user account", async () => { + const { + phone, + authToken: initialAuthToken, + kratosUserId, + } = await createIdentity() + + const initiated = await kratosInitiateTotp(initialAuthToken) + if (initiated instanceof Error) throw initiated + const { totpSecret, totpRegistrationId } = initiated + + { + const totpCode = authenticator.generate(totpSecret) + const validated = kratosValidateTotp({ + totpRegistrationId, + totpCode, + authToken: initialAuthToken, + }) + expect(validated).not.toBeInstanceOf(Error) + + const res = await validateKratosToken(initialAuthToken) + if (res instanceof Error) throw res + expect(res).toEqual( + expect.objectContaining({ + kratosUserId, + session: expect.any(Object), + }), + ) + + // wait for the identity to be updated? + // some cache or asynchronous method need to run on the kratos side? + await sleep(100) + const identity = await IdentityRepository().getIdentity(kratosUserId) + if (identity instanceof Error) throw identity + expect(identity.totpEnabled).toBe(true) + } + + { + const loginRes = await authService.loginToken({ phone }) + if (loginRes instanceof Error) throw loginRes + expect(loginRes.kratosUserId).toBeUndefined() + const { authToken } = loginRes + + const totpCode = authenticator.generate(totpSecret) as TotpCode + const res = await kratosElevatingSessionWithTotp({ + authToken, + totpCode, + }) + if (res instanceof Error) throw res + expect(res).toBe(true) + + await kratosRemoveTotp(kratosUserId) + + // wait for the identity to be updated? + // some cache or asynchronous method need to run on the kratos side? + await sleep(100) + const identity = await IdentityRepository().getIdentity(kratosUserId) + if (identity instanceof Error) throw identity + expect(identity.totpEnabled).toBe(false) + } + }) + + it("fails to change phone number from publicApi", async () => { + const { phone, authToken } = await createIdentity() + + const validated = await validateKratosToken(authToken) + if (validated instanceof Error) throw validated + expect(validated.session.identity.phone).toStrictEqual(phone) + + const res = await kratosPublic.createNativeSettingsFlow({ + xSessionToken: authToken, + }) + + const newPhone = randomPhone() + const err = await getError(() => + kratosPublic.updateSettingsFlow({ + flow: res.data.id, + updateSettingsFlowBody: { + method: "profile", + traits: { + phone: newPhone, + }, + }, + xSessionToken: authToken, + }), + ) + + expect(err).toBeTruthy() + }) + }) + + describe("user sessions", () => { + it("adds a new session for a new login", async () => { + const { phone, kratosUserId } = await createIdentity() + + const startingSessions = await listSessions(kratosUserId) + if (startingSessions instanceof Error) throw startingSessions + + await authService.loginToken({ phone }) + + const sessions = await listSessions(kratosUserId) + if (sessions instanceof Error) throw sessions + expect(sessions.length - startingSessions.length).toEqual(1) + }) + + it("return error on revoked session", async () => { + const { kratosUserId, authToken: token } = await createIdentity() + + await revokeSessions(kratosUserId) + const res = await validateKratosToken(token) + expect(res).toBeInstanceOf(AuthenticationKratosError) + }) + + it("revoke a user's second session only", async () => { + const { phone } = await createIdentity() + + // Session 1 + const session1 = await authService.loginToken({ phone }) + if (session1 instanceof Error) throw session1 + const session1Token = session1.authToken + + // Session 2 + const session2 = await authService.loginToken({ phone }) + if (session2 instanceof Error) throw session2 + const session2Token = session2.authToken + + // Session Details + // *caveat, you need to have at least 2 active sessions + // for 'listMySessions' to work properly if you only + // have 1 active session the data will come back null + const session1Details = await kratosPublic.listMySessions({ + xSessionToken: session1Token, + }) + const session1Id = session1Details.data[0].id + const session2Details = await kratosPublic.listMySessions({ + xSessionToken: session2Token, + }) + const session2Id = session2Details.data[0].id + expect(session1Id).toBeDefined() + expect(session2Id).toBeDefined() + + // Revoke Session 2 + await kratosPublic.performNativeLogout({ + performNativeLogoutBody: { + session_token: session2Token, + }, + }) + + const isSession1Valid = await validateKratosToken(session1Token) + const isSession2Valid = await validateKratosToken(session2Token) + expect(isSession1Valid).toBeDefined() + expect(isSession2Valid).toBeInstanceOf(KratosError) + }) + + it("extend session", async () => { + const { authToken } = await createIdentity() + + const res = await kratosPublic.toSession({ xSessionToken: authToken }) + const sessionKratos = res.data + if (!sessionKratos.expires_at) throw Error("should have expired_at") + const initialExpiresAt = new Date(sessionKratos.expires_at) + + const sessionId = sessionKratos.id as SessionId + + await extendSession(sessionId) + await sleep(200) + + const res2 = await kratosPublic.toSession({ xSessionToken: authToken }) + const newSession = res2.data + if (!newSession.expires_at) throw Error("should have expired_at") + const newExpiresAt = new Date(newSession.expires_at) + + expect(initialExpiresAt.getTime()).toBeLessThan(newExpiresAt.getTime()) + }) + }) + }) + + describe("admin api", () => { + it("revoke user session", async () => { + const { kratosUserId } = await createIdentity() + + const { data: dataBefore } = await kratosAdmin.listIdentitySessions({ + id: kratosUserId, + }) + expect(dataBefore.length).toBeGreaterThan(0) + + await revokeSessions(kratosUserId) + + const { data: dataAfter } = await kratosAdmin.listIdentitySessions({ + id: kratosUserId, + }) + expect(dataAfter.length).toEqual(0) + }) + + it("create a user with admin api, and can login with self api", async () => { + const phone = randomPhone() + const kratosUserId = await authService.createIdentityNoSession({ phone }) + if (kratosUserId instanceof Error) throw kratosUserId + + const res = await authService.loginToken({ phone }) + if (res instanceof Error) throw res + + expect(res.kratosUserId).toBe(kratosUserId) + }) + + describe.skip("update status", () => { + // Status on kratos is not implemented + const authService = AuthWithPhonePasswordlessService() + + let kratosUserId: UserId + const phone = randomPhone() + + it("deactivate user", async () => { + { + const res = await authService.createIdentityWithSession({ phone }) + if (res instanceof Error) throw res + kratosUserId = res.kratosUserId + } + await deactivateUser(kratosUserId) + await authService.loginToken({ phone }) + + const res = await authService.loginToken({ phone }) + expect(res).toBeInstanceOf(AuthenticationKratosError) + }) + + it("activate user", async () => { + await activateUser(kratosUserId) + const res = await authService.loginToken({ phone }) + if (res instanceof Error) throw res + expect(res.kratosUserId).toBe(kratosUserId) + }) + }) + }) + + describe("IdentityRepository", () => { + it("gets user id from phone", async () => { + const { phone, kratosUserId } = await createIdentity() + + const userId = await identities.getUserIdFromIdentifier(phone) + if (userId instanceof Error) throw userId + expect(userId).toBe(kratosUserId) + }) + }) +}) + +describe("phone+email schema", () => { + const authServiceEmail = AuthWithEmailPasswordlessService() + const authServicePhone = AuthWithPhonePasswordlessService() + const identities = IdentityRepository() + + describe("user", () => { + it("creates a user with phone", async () => { + const { phone, kratosUserId } = await createIdentity() + + const identity = await identities.getIdentity(kratosUserId) + if (identity instanceof Error) throw identity + expect(identity.phone).toBe(phone) + expect(identity.email).toBeUndefined() + + expect(await authServiceEmail.hasEmail({ kratosUserId })).toBe(false) + }) + + it("upgrades to phone+email schema", async () => { + const { kratosUserId } = await createIdentity() + + const email = randomEmail() + const res = await authServiceEmail.addUnverifiedEmailToIdentity({ + kratosUserId, + email, + }) + if (res instanceof Error) throw res + + const identity = await identities.getIdentity(kratosUserId) + if (identity instanceof Error) throw identity + expect(identity.schema).toBe("phone_email_no_password_v0") + expect(identity.email).toBe(email) + + expect(await authServiceEmail.hasEmail({ kratosUserId })).toBe(true) + expect(await authServiceEmail.isEmailVerified({ email })).toBe(false) + }) + + it("can't add same email to multiple identities", async () => { + const { kratosUserId } = await createIdentity() + const email = randomEmail() + await authServiceEmail.addUnverifiedEmailToIdentity({ + kratosUserId, + email, + }) + + const { kratosUserId: newkratosUserId } = await createIdentity() + const res = await authServiceEmail.addUnverifiedEmailToIdentity({ + kratosUserId: newkratosUserId, + email, + }) + expect(res).toBeInstanceOf(EmailAlreadyExistsError) + }) + + it("verifies email for identity", async () => { + const { kratosUserId } = await createIdentity() + const email = randomEmail() + await authServiceEmail.addUnverifiedEmailToIdentity({ + kratosUserId, + email, + }) + + const emailFlowId = await authServiceEmail.sendEmailWithCode({ email }) + if (emailFlowId instanceof Error) throw emailFlowId + + // TODO: look if there are rate limit on the side of kratos + const wrongCode = "000000" as EmailCode + let res = await authServiceEmail.validateCode({ + code: wrongCode, + emailFlowId, + }) + expect(res).toBeInstanceOf(EmailCodeInvalidError) + expect(await authServiceEmail.isEmailVerified({ email })).toBe(false) + + const code = await getEmailCode(email) + res = await authServiceEmail.validateCode({ + code, + emailFlowId, + }) + if (res instanceof Error) throw res + expect(res.email).toBe(email) + expect(await authServiceEmail.isEmailVerified({ email })).toBe(true) + }) + + it("fails to verify non-existent email", async () => { + const email = randomEmail() + const flow = await authServiceEmail.sendEmailWithCode({ email }) + if (flow instanceof Error) throw flow + + await expect(async () => getEmailCode(email)).rejects.toThrow() + }) + + it("gets login token using unverified email", async () => { + const { kratosUserId } = await createIdentity() + const email = randomEmail() + await authServiceEmail.addUnverifiedEmailToIdentity({ + kratosUserId, + email, + }) + + const res = await authServiceEmail.loginToken({ email }) + if (res instanceof Error) throw res + expect(res.kratosUserId).toBe(kratosUserId) + expect(res.authToken).toBeTruthy() + }) + + it("gets login token & correct schema using phone", async () => { + const { phone, kratosUserId } = await createIdentity() + const email = randomEmail() + await authServiceEmail.addUnverifiedEmailToIdentity({ + kratosUserId, + email, + }) + + const res = await authServicePhone.loginToken({ phone }) + if (res instanceof Error) throw res + expect(res.kratosUserId).toBe(kratosUserId) + expect(res.authToken).toBeTruthy() + + const identity = await identities.getIdentity(kratosUserId) + if (identity instanceof Error) throw identity + expect(identity.schema).toBe(SchemaIdType.PhoneEmailNoPasswordV0) + }) + + it("removes email from identity", async () => { + const { kratosUserId } = await createIdentity() + const email = randomEmail() + await authServiceEmail.addUnverifiedEmailToIdentity({ + kratosUserId, + email, + }) + + const res = await authServiceEmail.removeEmailFromIdentity({ kratosUserId }) + if (res instanceof Error) throw res + + const identity = await identities.getIdentity(kratosUserId) + if (identity instanceof Error) throw identity + expect(identity.schema).toBe(SchemaIdType.PhoneNoPasswordV0) + }) + + it("fails to remove phone if no email attached", async () => { + const { kratosUserId } = await createIdentity() + const res = await authServiceEmail.removePhoneFromIdentity({ kratosUserId }) + expect(res).toBeInstanceOf(IncompatibleSchemaUpgradeError) + }) + + it("removes phone from identity with verified email", async () => { + const { kratosUserId } = await createIdentity() + const email = randomEmail() + await authServiceEmail.addUnverifiedEmailToIdentity({ + kratosUserId, + email, + }) + + const emailFlowId = await authServiceEmail.sendEmailWithCode({ email }) + if (emailFlowId instanceof Error) throw emailFlowId + const code = await getEmailCode(email) + const validated = await authServiceEmail.validateCode({ + code, + emailFlowId, + }) + if (validated instanceof Error) throw validated + + const res = await authServiceEmail.removePhoneFromIdentity({ kratosUserId }) + expect(res).not.toBeInstanceOf(Error) + + const identity = await identities.getIdentity(kratosUserId) + if (identity instanceof Error) throw identity + expect(identity.schema).toBe(SchemaIdType.EmailNoPasswordV0) + }) + + it("fails to remove phone with unverified email", async () => { + const { kratosUserId } = await createIdentity() + const email = randomEmail() + await authServiceEmail.addUnverifiedEmailToIdentity({ + kratosUserId, + email, + }) + + const res = await authServiceEmail.removePhoneFromIdentity({ kratosUserId }) + expect(res).toBeInstanceOf(EmailUnverifiedError) + }) + }) + + describe("IdentityRepository", () => { + it("gets userId via email", async () => { + const { kratosUserId } = await createIdentity() + + const email = randomEmail() + const res = await authServiceEmail.addUnverifiedEmailToIdentity({ + kratosUserId, + email, + }) + if (res instanceof Error) throw res + + const userId = await identities.getUserIdFromIdentifier(email) + if (userId instanceof Error) throw userId + expect(userId).toBe(kratosUserId) + }) + }) +}) + +describe("username+password schema (device account)", () => { + const authServiceUsername = AuthWithUsernamePasswordDeviceIdService() + const authServicePhone = AuthWithPhonePasswordlessService() + const identities = IdentityRepository() + + it("create an account", async () => { + const username = randomUsername() + const password = randomPassword() + + const res = await authServiceUsername.createIdentityWithSession({ + username, + password, + }) + if (res instanceof Error) throw res + const { kratosUserId } = res + + const newIdentity = await identities.getIdentity(kratosUserId) + if (newIdentity instanceof Error) throw newIdentity + expect(newIdentity.schema).toBe(SchemaIdType.UsernamePasswordDeviceIdV0) + expect(newIdentity.username).toBe(username) + }) + + it("upgrade account", async () => { + const username = randomUsername() + const password = randomPassword() + const usernameResult = await authServiceUsername.createIdentityWithSession({ + username, + password, + }) + if (usernameResult instanceof Error) throw usernameResult + const { kratosUserId } = usernameResult + + const phone = randomPhone() + const res = await authServicePhone.updateIdentityFromDeviceAccount({ + phone, + userId: kratosUserId, + }) + if (res instanceof Error) throw res + + expect(res.phone).toBe(phone) + expect(res.id).toBe(kratosUserId) + }) +}) diff --git a/core/api/test/mocks/price.ts b/core/api/test/mocks/price.ts deleted file mode 100644 index 8a7fe08054..0000000000 --- a/core/api/test/mocks/price.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { NotImplementedError } from "@/domain/errors" - -export const PriceService = (): IPriceService => { - return { - getSatRealTimePrice: async () => new NotImplementedError(), - getUsdCentRealTimePrice: async () => new NotImplementedError(), - listHistory: async () => new NotImplementedError(), - listCurrencies: async () => [ - { - code: "EUR", - symbol: "€", - name: "Euro", - flag: "🇪🇺", - fractionDigits: 2, - } as PriceCurrency, - { - code: "CRC", - symbol: "₡", - name: "Costa Rican Colón", - flag: "🇨🇷", - fractionDigits: 2, - } as PriceCurrency, - { - code: "USD", - symbol: "$", - name: "US Dollar", - flag: "🇺🇸", - fractionDigits: 2, - } as PriceCurrency, - ], - } -} diff --git a/core/api/test/mocks/twilio.ts b/core/api/test/mocks/twilio.ts deleted file mode 100644 index 5ca8a08d0a..0000000000 --- a/core/api/test/mocks/twilio.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { yamlConfig } from "@/config" - -export const TwilioClient = () => { - const initiateVerify = async () => { - return new Promise((resolve) => resolve(true)) - } - - const validateVerify = async () => { - return new Promise((resolve) => resolve(true)) - } - - const getCarrier = async (phone: PhoneNumber) => { - const entry = yamlConfig.test_accounts.find((item) => item.phone === phone) - - return new Promise((resolve) => { - if (!entry) return resolve(null) - - return resolve({ - carrier: { - type: "mobile" as CarrierType, - name: "", - mobile_network_code: "", - mobile_country_code: "", - error_code: "", - }, - countryCode: "US", - }) - }) - } - - return { initiateVerify, validateVerify, getCarrier } -} diff --git a/core/api/test/unit/services/kratos/identity.spec.ts b/core/api/test/unit/services/kratos/identity.spec.ts new file mode 100644 index 0000000000..349f46714a --- /dev/null +++ b/core/api/test/unit/services/kratos/identity.spec.ts @@ -0,0 +1,17 @@ +import { getNextPageToken } from "@/services/kratos" + +describe("decoding link header", () => { + const withNext = + '; rel="first",; rel="next"' + + const withoutNext = + '; rel="first"' + + it("try decoding link successfully", () => { + expect(getNextPageToken(withNext)).toBe("h9LfEKUiFoLH2R0A") + }) + + it("should be undefined when no more next is present", () => { + expect(getNextPageToken(withoutNext)).toBe(undefined) + }) +})