From 62f4b3471c677896f8571d8c797b29bfdd498c9a Mon Sep 17 00:00:00 2001 From: Atmosfearful <88635679+Atmosfearful@users.noreply.github.com> Date: Mon, 29 Jan 2024 22:04:41 -0800 Subject: [PATCH] Simplify profile auth & signature flow (#2012) * Remove JWT logic from api * add tests * use validateSignature in handlers * npm version major * use wallet regex * cleanup put schema and add tests * add FirestoreUserDoc schema and add more tests * merge firebase tools and cleanup * fix: undefined nonce * remove session plugin * fix type definitions * abstract authenticateProfile prehandler * Add fallback for SIGN_PROFILE_MESSAGE * push test debug * resolve conflicts & rebase staging * fix import * pass tests and remove debug * add better fallback * generate:types * correct status code in authenticate middleware * add debug statements * push more debug * remove debug and change log * fix: empty strings instead of undefined * fix tests --- carbonmark-api/package.json | 5 +- carbonmark-api/postman_collection.json | 84 -------- .../.generated/mocks/digitalCarbon.mocks.ts | 22 ++ .../.generated/types/digitalCarbon.types.ts | 35 ++++ carbonmark-api/src/app.constants.ts | 6 + carbonmark-api/src/fastify.d.ts | 16 ++ carbonmark-api/src/models/User.model.ts | 1 + .../src/models/UserProfile.model.ts | 32 ++- carbonmark-api/src/plugins/bearer.ts | 35 ---- carbonmark-api/src/plugins/caching.ts | 6 - carbonmark-api/src/plugins/firebase.ts | 6 - carbonmark-api/src/plugins/session.ts | 20 -- carbonmark-api/src/plugins/users.ts | 19 -- .../src/routes/listings/[id]/get.ts | 4 +- .../src/routes/login/post.schema.ts | 26 --- carbonmark-api/src/routes/login/post.ts | 37 ---- .../src/routes/login/verify/post.schema.ts | 27 --- .../src/routes/login/verify/post.ts | 56 ----- .../src/routes/retirements/[id]/get.ts | 2 +- .../src/routes/users/[walletOrHandle]/get.ts | 1 + .../src/routes/users/[wallet]/put.schema.ts | 27 ++- .../src/routes/users/[wallet]/put.ts | 52 +++-- carbonmark-api/src/routes/users/auth.ts | 48 +++++ .../src/routes/users/post.schema.ts | 17 +- carbonmark-api/src/routes/users/post.ts | 77 ++++--- carbonmark-api/src/utils/crypto.utils.ts | 30 ++- carbonmark-api/src/utils/firebase.utils.ts | 4 - .../src/utils/helpers/users.utils.ts | 48 +++-- .../test/flows/authentication.test.ts | 100 --------- carbonmark-api/test/helper.ts | 28 +-- .../test/routes/projects/get.test.ts | 5 +- .../test/routes/retirements/[id]/get.test.ts | 2 - .../test/routes/retirements/get.test.mocks.ts | 4 +- carbonmark-api/test/routes/users/get.test.ts | 111 +++++----- carbonmark-api/test/routes/users/post.test.ts | 186 ++++++++++++++-- carbonmark-api/test/routes/users/put.test.ts | 198 ++++++++++++++++-- carbonmark-api/test/test.constants.ts | 3 +- carbonmark-api/test/test.utils.ts | 60 +++--- .../test/utils/crypto.utils.test.ts | 76 ++++++- .../test/utils/helpers/users.utils.test.ts | 10 + package-lock.json | 108 +--------- 41 files changed, 860 insertions(+), 774 deletions(-) create mode 100644 carbonmark-api/src/fastify.d.ts delete mode 100644 carbonmark-api/src/plugins/bearer.ts delete mode 100644 carbonmark-api/src/plugins/session.ts delete mode 100644 carbonmark-api/src/plugins/users.ts delete mode 100644 carbonmark-api/src/routes/login/post.schema.ts delete mode 100644 carbonmark-api/src/routes/login/post.ts delete mode 100644 carbonmark-api/src/routes/login/verify/post.schema.ts delete mode 100644 carbonmark-api/src/routes/login/verify/post.ts create mode 100644 carbonmark-api/src/routes/users/auth.ts delete mode 100644 carbonmark-api/src/utils/firebase.utils.ts delete mode 100644 carbonmark-api/test/flows/authentication.test.ts create mode 100644 carbonmark-api/test/utils/helpers/users.utils.test.ts diff --git a/carbonmark-api/package.json b/carbonmark-api/package.json index 64631e6ec3..daa0031fdb 100644 --- a/carbonmark-api/package.json +++ b/carbonmark-api/package.json @@ -1,6 +1,6 @@ { "name": "@klimadao/carbonmark-api", - "version": "5.3.6", + "version": "6.0.0", "description": "An API for exploring Carbonmark project data, prices and activity.", "main": "app.ts", "scripts": { @@ -17,16 +17,13 @@ "license": "ISC", "dependencies": { "@fastify/autoload": "^5.0.0", - "@fastify/cookie": "^8.3.0", "@fastify/cors": "^8.3.0", - "@fastify/jwt": "^6.7.1", "@fastify/rate-limit": "^8.0.1", "@fastify/response-validation": "^2.3.1", "@fastify/sensible": "^5.0.0", "@fastify/session": "^10.3.0", "@fastify/swagger": "^8.8.0", "@fastify/type-provider-typebox": "^3.1.0", - "@mgcrea/fastify-session": "^1.1.0", "@sanity/client": "^6.1.2", "@sinclair/typebox": "^0.28.5", "dotenv": "^16.1.4", diff --git a/carbonmark-api/postman_collection.json b/carbonmark-api/postman_collection.json index 8bf5cb31f6..73b98d6042 100644 --- a/carbonmark-api/postman_collection.json +++ b/carbonmark-api/postman_collection.json @@ -604,48 +604,6 @@ }, "response": [] }, - { - "name": "loginUser", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [""], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "if (pm.environment.get(\"SKIP_MUTATIONS\") === \"true\") {", - " postman.setNextRequest(null);", - "}" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"wallet\":\"{{wallet}}\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{url}}/users/login", - "host": ["{{url}}"], - "path": ["users", "login"] - } - }, - "response": [] - }, { "name": "getListingById", "request": { @@ -661,48 +619,6 @@ } }, "response": [] - }, - { - "name": "verifyUser", - "event": [ - { - "listen": "prerequest", - "script": { - "exec": [""], - "type": "text/javascript" - } - }, - { - "listen": "test", - "script": { - "exec": [ - "if (pm.environment.get(\"SKIP_MUTATIONS\") === \"true\") {", - " postman.setNextRequest(null);", - "}" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"wallet\":\"{{wallet}}\",\n \"signature\":\"{{signature}}\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{url}}/users/login/verify", - "host": ["{{url}}"], - "path": ["users", "login", "verify"] - } - }, - "response": [] } ], "event": [ diff --git a/carbonmark-api/src/.generated/mocks/digitalCarbon.mocks.ts b/carbonmark-api/src/.generated/mocks/digitalCarbon.mocks.ts index 6b5212472f..da4d75ad3b 100644 --- a/carbonmark-api/src/.generated/mocks/digitalCarbon.mocks.ts +++ b/carbonmark-api/src/.generated/mocks/digitalCarbon.mocks.ts @@ -2224,6 +2224,7 @@ export const aRetire = (overrides?: Partial, _relationshipsToOmit: Set, _relationship pool_not_starts_with_nocase: overrides && overrides.hasOwnProperty('pool_not_starts_with_nocase') ? overrides.pool_not_starts_with_nocase! : 'ratione', pool_starts_with: overrides && overrides.hasOwnProperty('pool_starts_with') ? overrides.pool_starts_with! : 'dignissimos', pool_starts_with_nocase: overrides && overrides.hasOwnProperty('pool_starts_with_nocase') ? overrides.pool_starts_with_nocase! : 'ratione', + provenance: overrides && overrides.hasOwnProperty('provenance') ? overrides.provenance! : 'ut', + provenance_: overrides && overrides.hasOwnProperty('provenance_') ? overrides.provenance_! : relationshipsToOmit.has('ProvenanceRecord_Filter') ? {} as ProvenanceRecord_Filter : aProvenanceRecord_Filter({}, relationshipsToOmit), + provenance_contains: overrides && overrides.hasOwnProperty('provenance_contains') ? overrides.provenance_contains! : 'sint', + provenance_contains_nocase: overrides && overrides.hasOwnProperty('provenance_contains_nocase') ? overrides.provenance_contains_nocase! : 'qui', + provenance_ends_with: overrides && overrides.hasOwnProperty('provenance_ends_with') ? overrides.provenance_ends_with! : 'nihil', + provenance_ends_with_nocase: overrides && overrides.hasOwnProperty('provenance_ends_with_nocase') ? overrides.provenance_ends_with_nocase! : 'voluptatum', + provenance_gt: overrides && overrides.hasOwnProperty('provenance_gt') ? overrides.provenance_gt! : 'doloremque', + provenance_gte: overrides && overrides.hasOwnProperty('provenance_gte') ? overrides.provenance_gte! : 'dolorum', + provenance_in: overrides && overrides.hasOwnProperty('provenance_in') ? overrides.provenance_in! : ['laudantium'], + provenance_lt: overrides && overrides.hasOwnProperty('provenance_lt') ? overrides.provenance_lt! : 'cum', + provenance_lte: overrides && overrides.hasOwnProperty('provenance_lte') ? overrides.provenance_lte! : 'modi', + provenance_not: overrides && overrides.hasOwnProperty('provenance_not') ? overrides.provenance_not! : 'ut', + provenance_not_contains: overrides && overrides.hasOwnProperty('provenance_not_contains') ? overrides.provenance_not_contains! : 'quidem', + provenance_not_contains_nocase: overrides && overrides.hasOwnProperty('provenance_not_contains_nocase') ? overrides.provenance_not_contains_nocase! : 'et', + provenance_not_ends_with: overrides && overrides.hasOwnProperty('provenance_not_ends_with') ? overrides.provenance_not_ends_with! : 'eum', + provenance_not_ends_with_nocase: overrides && overrides.hasOwnProperty('provenance_not_ends_with_nocase') ? overrides.provenance_not_ends_with_nocase! : 'nulla', + provenance_not_in: overrides && overrides.hasOwnProperty('provenance_not_in') ? overrides.provenance_not_in! : ['sunt'], + provenance_not_starts_with: overrides && overrides.hasOwnProperty('provenance_not_starts_with') ? overrides.provenance_not_starts_with! : 'omnis', + provenance_not_starts_with_nocase: overrides && overrides.hasOwnProperty('provenance_not_starts_with_nocase') ? overrides.provenance_not_starts_with_nocase! : 'nostrum', + provenance_starts_with: overrides && overrides.hasOwnProperty('provenance_starts_with') ? overrides.provenance_starts_with! : 'recusandae', + provenance_starts_with_nocase: overrides && overrides.hasOwnProperty('provenance_starts_with_nocase') ? overrides.provenance_starts_with_nocase! : 'dignissimos', retirementMessage: overrides && overrides.hasOwnProperty('retirementMessage') ? overrides.retirementMessage! : 'hic', retirementMessage_contains: overrides && overrides.hasOwnProperty('retirementMessage_contains') ? overrides.retirementMessage_contains! : 'ipsa', retirementMessage_contains_nocase: overrides && overrides.hasOwnProperty('retirementMessage_contains_nocase') ? overrides.retirementMessage_contains_nocase! : 'excepturi', diff --git a/carbonmark-api/src/.generated/types/digitalCarbon.types.ts b/carbonmark-api/src/.generated/types/digitalCarbon.types.ts index 3e3de7ab9f..c5d63f0874 100644 --- a/carbonmark-api/src/.generated/types/digitalCarbon.types.ts +++ b/carbonmark-api/src/.generated/types/digitalCarbon.types.ts @@ -3349,6 +3349,8 @@ export type Retire = { klimaRetire: Maybe; /** Pool credit was sourced from, if any */ pool: Maybe; + /** Final provenance record created by this retirement */ + provenance: Maybe; /** Specific retirement message */ retirementMessage: Scalars['String']; /** Retiree address */ @@ -3503,6 +3505,27 @@ export type Retire_Filter = { pool_not_starts_with_nocase: InputMaybe; pool_starts_with: InputMaybe; pool_starts_with_nocase: InputMaybe; + provenance: InputMaybe; + provenance_: InputMaybe; + provenance_contains: InputMaybe; + provenance_contains_nocase: InputMaybe; + provenance_ends_with: InputMaybe; + provenance_ends_with_nocase: InputMaybe; + provenance_gt: InputMaybe; + provenance_gte: InputMaybe; + provenance_in: InputMaybe>; + provenance_lt: InputMaybe; + provenance_lte: InputMaybe; + provenance_not: InputMaybe; + provenance_not_contains: InputMaybe; + provenance_not_contains_nocase: InputMaybe; + provenance_not_ends_with: InputMaybe; + provenance_not_ends_with_nocase: InputMaybe; + provenance_not_in: InputMaybe>; + provenance_not_starts_with: InputMaybe; + provenance_not_starts_with_nocase: InputMaybe; + provenance_starts_with: InputMaybe; + provenance_starts_with_nocase: InputMaybe; retirementMessage: InputMaybe; retirementMessage_contains: InputMaybe; retirementMessage_contains_nocase: InputMaybe; @@ -3614,6 +3637,18 @@ export enum Retire_OrderBy { pool__name = 'pool__name', pool__nextSnapshotDayID = 'pool__nextSnapshotDayID', pool__supply = 'pool__supply', + provenance = 'provenance', + provenance__createdAt = 'provenance__createdAt', + provenance__id = 'provenance__id', + provenance__originalAmount = 'provenance__originalAmount', + provenance__receiver = 'provenance__receiver', + provenance__remainingAmount = 'provenance__remainingAmount', + provenance__sender = 'provenance__sender', + provenance__token = 'provenance__token', + provenance__tokenId = 'provenance__tokenId', + provenance__transactionHash = 'provenance__transactionHash', + provenance__transactionType = 'provenance__transactionType', + provenance__updatedAt = 'provenance__updatedAt', retirementMessage = 'retirementMessage', retiringAddress = 'retiringAddress', retiringAddress__id = 'retiringAddress__id', diff --git a/carbonmark-api/src/app.constants.ts b/carbonmark-api/src/app.constants.ts index 928dd9b74b..0a19c5ca4d 100644 --- a/carbonmark-api/src/app.constants.ts +++ b/carbonmark-api/src/app.constants.ts @@ -114,3 +114,9 @@ export const ICR_API = ( return { ICR_API_URL: API_CONFIG.url, ICR_API_KEY: API_CONFIG.apiKey }; }; +/** Message shared with frontend, to be combined with user's nonce and signed by private key. */ +export const SIGN_PROFILE_MESSAGE = + process.env.SIGN_PROFILE_MESSAGE || "VerifyCarbonmarkProfileEdit"; + +/** Ethereum 0x address */ +export const VALID_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/; diff --git a/carbonmark-api/src/fastify.d.ts b/carbonmark-api/src/fastify.d.ts new file mode 100644 index 0000000000..03785441c8 --- /dev/null +++ b/carbonmark-api/src/fastify.d.ts @@ -0,0 +1,16 @@ +import "fastify"; +import { UserProfile } from "./models/UserProfile.model"; +import { ILcacheStorage } from "./plugins/caching"; + +declare module "fastify" { + interface FastifyRequest { + /** Authenticated routes may pass the userDoc down from preHandler */ + userProfile?: UserProfile | null; + } + interface FastifyInstance { + lcache: ILcacheStorage; + } + interface FastifyInstance { + firebase: FirebaseInstance; + } +} diff --git a/carbonmark-api/src/models/User.model.ts b/carbonmark-api/src/models/User.model.ts index 9bcabbeac5..3db48bfb61 100644 --- a/carbonmark-api/src/models/User.model.ts +++ b/carbonmark-api/src/models/User.model.ts @@ -17,6 +17,7 @@ export const UserModel = Type.Object({ listings: Type.Optional(Type.Array(ListingModel)), activities: Type.Optional(Type.Array(ActivityModel)), assets: Type.Optional(Type.Array(AssetModel)), + nonce: Type.Optional(Type.Number()), }); export type User = Static; diff --git a/carbonmark-api/src/models/UserProfile.model.ts b/carbonmark-api/src/models/UserProfile.model.ts index 4331f809fd..dd18212b9f 100644 --- a/carbonmark-api/src/models/UserProfile.model.ts +++ b/carbonmark-api/src/models/UserProfile.model.ts @@ -1,15 +1,33 @@ import { Static, Type } from "@sinclair/typebox"; -import { Nullable } from "./Utility.model"; -//This model matches the document structure in https://console.firebase.google.com/project/klimadao-staging +/** + * This model matches the document structure in https://console.firebase.google.com/project/klimadao-staging + * Should not be documented in public docs. + * At time of writing we only use this for types, we don't validate firestore writes against this schema. + * + * Updated 09.12.2023 - added nonce + */ + export const UserProfileModel = Type.Object({ - handle: Nullable(Type.String()), - username: Type.String(), - description: Nullable(Type.String()), - profileImgUrl: Nullable(Type.String()), - updatedAt: Type.Number(), + /** Unique, immutable, case-insensitive handle */ + handle: Type.String({ minLength: 3, maxLength: 24 }), + /** Unix timestamp ms */ createdAt: Type.Number(), + /** Unix timestamp ms */ + updatedAt: Type.Number(), + /** Lower case wallet address which owns the profile */ address: Type.String(), + /** Editable username */ + username: Type.String({ minLength: 2 }), + /** Optional profile description. May also be empty string. */ + description: Type.Optional(Type.String()), + /** Optional image url. May also be empty string. */ + profileImgUrl: Type.Optional(Type.String()), + /** + * Nonce, incremented once per edit, may not be present + * Ensures the same message hash can never be reused (replay attack) + * */ + nonce: Type.Optional(Type.Number()), }); export type UserProfile = Static; diff --git a/carbonmark-api/src/plugins/bearer.ts b/carbonmark-api/src/plugins/bearer.ts deleted file mode 100644 index e390a373ac..0000000000 --- a/carbonmark-api/src/plugins/bearer.ts +++ /dev/null @@ -1,35 +0,0 @@ -import fastifyJwt from "@fastify/jwt"; -import fp from "fastify-plugin"; -import { isNil } from "lodash"; - -const SECRET = process.env.JWT_SECRET; - -if (isNil(SECRET)) { - throw new Error(`Missing JWT_SECRET env`); -} - -export default fp(async (fastify) => { - await fastify.register(fastifyJwt, { - secret: SECRET, - }); - - await fastify.decorate( - "authenticate", - async function (request, reply): Promise { - try { - /** We need the ability to disable authentication when testing */ - if (!process.env.IGNORE_AUTH) { - await request.jwtVerify(); - } - } catch (err) { - reply.send(err); - } - } - ); -}); - -declare module "fastify" { - export interface FastifyInstance { - authenticate(request: FastifyRequest, reply: FastifyReply): Promise; - } -} diff --git a/carbonmark-api/src/plugins/caching.ts b/carbonmark-api/src/plugins/caching.ts index 650ba42973..c60ce3d003 100644 --- a/carbonmark-api/src/plugins/caching.ts +++ b/carbonmark-api/src/plugins/caching.ts @@ -13,12 +13,6 @@ export default fp(async function (fastify) { } }); -declare module "fastify" { - export interface FastifyInstance { - lcache: ILcacheStorage | null; - } -} - export type CachedResponse = | { payload: T; diff --git a/carbonmark-api/src/plugins/firebase.ts b/carbonmark-api/src/plugins/firebase.ts index 71284ada6b..3027935f25 100644 --- a/carbonmark-api/src/plugins/firebase.ts +++ b/carbonmark-api/src/plugins/firebase.ts @@ -39,9 +39,3 @@ export default fp(async function (fastify: FastifyInstance) { }); export type FirebaseInstance = admin.app.App; - -declare module "fastify" { - export interface FastifyInstance { - firebase: FirebaseInstance; - } -} diff --git a/carbonmark-api/src/plugins/session.ts b/carbonmark-api/src/plugins/session.ts deleted file mode 100644 index d5fe6a6cd1..0000000000 --- a/carbonmark-api/src/plugins/session.ts +++ /dev/null @@ -1,20 +0,0 @@ -import fc from "@fastify/cookie"; -import fs from "@mgcrea/fastify-session"; -import fp from "fastify-plugin"; - -const SESSION_SECRET = "env_key"; //@todo use a real key here -const SESSION_TTL = 864e3; // 1 day in seconds - -export default fp(async function (fastify) { - await fastify.register(fc); - await fastify.register(fs, { - secret: SESSION_SECRET, - cookie: { maxAge: SESSION_TTL }, - }); -}); - -declare module "fastify" { - interface Session { - token: string; - } -} diff --git a/carbonmark-api/src/plugins/users.ts b/carbonmark-api/src/plugins/users.ts deleted file mode 100644 index 74572175e3..0000000000 --- a/carbonmark-api/src/plugins/users.ts +++ /dev/null @@ -1,19 +0,0 @@ -import fp from "fastify-plugin"; - -type UserNonce = { - walletAddress: string; - nonce: string; -}; - -/** - * Add a shared users object to the fastify instance - */ -export default fp(async (fastify) => { - await fastify.decorate("users", {}); -}); - -declare module "fastify" { - export interface FastifyInstance { - users: { [walletAddress: string]: UserNonce }; - } -} diff --git a/carbonmark-api/src/routes/listings/[id]/get.ts b/carbonmark-api/src/routes/listings/[id]/get.ts index 588b78bc11..a7e359c353 100644 --- a/carbonmark-api/src/routes/listings/[id]/get.ts +++ b/carbonmark-api/src/routes/listings/[id]/get.ts @@ -3,7 +3,7 @@ import { gql_sdk } from "../../../utils/gqlSdk"; import { getListingById } from "../../../utils/helpers/listings.utils"; import { Params, Querystring, schema } from "./get.schema"; -const handler = (fastify: FastifyInstance) => +const handler = () => async function ( request: FastifyRequest<{ Params: Params; @@ -29,6 +29,6 @@ export default async (fastify: FastifyInstance) => await fastify.route({ method: "GET", url: "/listings/:id", - handler: handler(fastify), + handler: handler(), schema: schema, }); diff --git a/carbonmark-api/src/routes/login/post.schema.ts b/carbonmark-api/src/routes/login/post.schema.ts deleted file mode 100644 index b94bb278dd..0000000000 --- a/carbonmark-api/src/routes/login/post.schema.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Type } from "@sinclair/typebox"; - -export const schema = { - summary: "Get nonce", - description: - "Provides the user with a nonce to be included in the next signature. Consumed by /verify endpoint.", - tags: ["Auth"], - body: Type.Object( - { - wallet: Type.String({ minLength: 26, maxLength: 64 }), - }, - { required: ["wallet"] } - ), - response: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: Type.Object({ - nonce: Type.String(), - }), - }, - }, - }, - }, -}; diff --git a/carbonmark-api/src/routes/login/post.ts b/carbonmark-api/src/routes/login/post.ts deleted file mode 100644 index 00f52d1e79..0000000000 --- a/carbonmark-api/src/routes/login/post.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { generateNonce } from "../../utils/crypto.utils"; -import { schema } from "./post.schema"; - -type Body = { - wallet: string; -}; - -function handler(request: FastifyRequest<{ Body: Body }>, reply: FastifyReply) { - const users = request.server.users; - - // Get the wallet address from the request body - const walletAddress = request.body.wallet; - // If the wallet address is not provided, return a 400 Bad Request error - if (!walletAddress) { - return reply.code(400).send("Bad Request"); - } - // Check if the wallet address is already in the users object - if (users[walletAddress]) { - // If the wallet address is found, return the nonce associated with it - return reply.send({ nonce: users[walletAddress].nonce }); - } - // Generate a new nonce - const nonce = generateNonce(); - // Add the wallet address and nonce to the users object - users[walletAddress] = { walletAddress, nonce }; - // Return the nonce - return reply.send({ nonce }); -} - -export default async (fastify: FastifyInstance) => - await fastify.route({ - method: "POST", - url: "/login", - schema, - handler, - }); diff --git a/carbonmark-api/src/routes/login/verify/post.schema.ts b/carbonmark-api/src/routes/login/verify/post.schema.ts deleted file mode 100644 index d84c5de317..0000000000 --- a/carbonmark-api/src/routes/login/verify/post.schema.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Type } from "@sinclair/typebox"; - -export const schema = { - summary: "Verify signed data", - description: - "Provide a signed hash to receive a JWT token to be consumed by PUT or POST requests.", - tags: ["Auth"], - body: Type.Object( - { - wallet: Type.String({ minLength: 26, maxLength: 64 }), - signature: Type.String(), - }, - { required: ["wallet", "signature"] } - ), - response: { - 200: { - description: "Successful response", - content: { - "application/json": { - schema: Type.Object({ - token: Type.String(), - }), - }, - }, - }, - }, -}; diff --git a/carbonmark-api/src/routes/login/verify/post.ts b/carbonmark-api/src/routes/login/verify/post.ts deleted file mode 100644 index 5942e68452..0000000000 --- a/carbonmark-api/src/routes/login/verify/post.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as ethers from "ethers"; -import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { generateNonce } from "../../../utils/crypto.utils"; -import { schema } from "./post.schema"; - -type Body = { - signature: string; - wallet: string; -}; - -const handler = (fastify: FastifyInstance) => - function (request: FastifyRequest<{ Body: Body }>, reply: FastifyReply) { - const users = request.server.users; - - // Destructure the wallet address and signature from the request body - const { signature, wallet } = request.body; - - // Get the user from the users object - const dbUser = users[wallet]; - - // Create the signed message to verify - const signedMessage = process.env.AUTHENTICATION_MESSAGE + dbUser.nonce; - - // Verify the signature - const signerWalletAddress = ethers.utils.verifyMessage( - signedMessage, - signature - ); - - // If the signature is invalid, send a 401 Unauthorized response - if ( - signerWalletAddress.toLowerCase() !== dbUser.walletAddress.toLowerCase() - ) { - return reply.code(401).send(`Unauthorized: Invalid signature`); - } - - // Create a JWT token for the user - const token = fastify.jwt.sign({ wallet }); - - // Save the token to the session - request.session.token = token; - - // Generate a new nonce for the user - users[wallet].nonce = generateNonce(); - - // Send the token back to the client - return reply.send({ token }); - }; - -export default async (fastify: FastifyInstance) => - await fastify.route({ - method: "POST", - url: "/login/verify", - handler: handler(fastify), - schema, - }); diff --git a/carbonmark-api/src/routes/retirements/[id]/get.ts b/carbonmark-api/src/routes/retirements/[id]/get.ts index 7e8f6a7b00..45a84714f6 100644 --- a/carbonmark-api/src/routes/retirements/[id]/get.ts +++ b/carbonmark-api/src/routes/retirements/[id]/get.ts @@ -29,8 +29,8 @@ const handler = (fastify: FastifyInstance) => // Add retiree profile information retirement.retireeProfile = (await getProfileByAddress({ - firebase: fastify.firebase, address: retirement.retiringAddress, + firebase: fastify.firebase, })) || undefined; return reply.send(JSON.stringify(retirement)); diff --git a/carbonmark-api/src/routes/users/[walletOrHandle]/get.ts b/carbonmark-api/src/routes/users/[walletOrHandle]/get.ts index ba36f03897..34fa4ffd58 100644 --- a/carbonmark-api/src/routes/users/[walletOrHandle]/get.ts +++ b/carbonmark-api/src/routes/users/[walletOrHandle]/get.ts @@ -110,6 +110,7 @@ const handler = (fastify: FastifyInstance) => updatedAt: profile?.updatedAt || 0, username: profile?.username || "", wallet: profile.address, + nonce: profile.nonce, listings, activities, assets, diff --git a/carbonmark-api/src/routes/users/[wallet]/put.schema.ts b/carbonmark-api/src/routes/users/[wallet]/put.schema.ts index a3ed8c94bd..896d6a2f88 100644 --- a/carbonmark-api/src/routes/users/[wallet]/put.schema.ts +++ b/carbonmark-api/src/routes/users/[wallet]/put.schema.ts @@ -1,13 +1,23 @@ import { Type } from "@sinclair/typebox"; +import { VALID_ADDRESS_REGEX } from "../../../app.constants"; -export const RequestBody = Type.Object({ - wallet: Type.String({ minLength: 3 }), - username: Type.String({ minLength: 2 }), - description: Type.String({ minLength: 2, maxLength: 500 }), - profileImgUrl: Type.Optional(Type.String()), - handle: Type.Optional(Type.String({ minLength: 3, maxLength: 24 })), +export const RequestBody = Type.Object( + { + username: Type.Optional(Type.String({ minLength: 2 })), + description: Type.Optional(Type.String({ maxLength: 500 })), + profileImgUrl: Type.Optional(Type.String()), + }, + { + description: "One or more fields to update.", + } +); + +export const ResponseBody = Type.Object({ + address: Type.String({ pattern: VALID_ADDRESS_REGEX.source }), + nonce: Type.Number(), }); +/** /users/:wallet */ export const Params = Type.Object( { wallet: Type.String({ @@ -19,16 +29,15 @@ export const Params = Type.Object( ); export const schema = { - summary: "Update user profile", + summary: "Update an existing user profile", tags: ["Users"], - params: Params, body: RequestBody, response: { 200: { description: "Successful response", content: { "application/json": { - schema: RequestBody, + schema: ResponseBody, }, }, }, diff --git a/carbonmark-api/src/routes/users/[wallet]/put.ts b/carbonmark-api/src/routes/users/[wallet]/put.ts index 174f417419..ac0a33fd0e 100644 --- a/carbonmark-api/src/routes/users/[wallet]/put.ts +++ b/carbonmark-api/src/routes/users/[wallet]/put.ts @@ -1,38 +1,54 @@ import { Static } from "@sinclair/typebox"; import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { RequestBody, schema } from "./put.schema"; +import { UserProfile } from "../../../models/UserProfile.model"; +import { authenticateProfile } from "../auth"; +import { Params, RequestBody, schema } from "./put.schema"; + +type PutRequest = FastifyRequest<{ + Body: Static; + Params: Static; +}>; const handler = (fastify: FastifyInstance) => - async function ( - request: FastifyRequest<{ Body: Static }>, - reply: FastifyReply - ) { - // Destructure the wallet, username, and description properties from the request body - const { wallet, username, description, profileImgUrl } = request.body; + async function (request: PutRequest, reply: FastifyReply) { + const { username, description, profileImgUrl } = request.body; + const { wallet } = request.params; + const userProfile = request.userProfile; try { - const updatedData = { - username, - description, + if (!userProfile) { + return reply + .code(404) + .send({ error: "No user profile found for given handle or address" }); + } + + const updatedData: Partial = { + // username min 2 chars + username: username || userProfile.username || userProfile.handle, + description: description ?? userProfile.description ?? "", updatedAt: Date.now(), - profileImgUrl: - profileImgUrl && profileImgUrl.length ? profileImgUrl : null, + profileImgUrl: profileImgUrl ?? userProfile.profileImgUrl ?? "", + nonce: (userProfile.nonce ?? 0) + 1, + // handle is not editable }; + const db = fastify.firebase.firestore(); // Try updating the user document with the specified data - await fastify.firebase - .firestore() + await db .collection("users") .doc(wallet.toUpperCase()) .update(updatedData); - // If the update is successful, return the request body - return reply.send(request.body); + // If the update is successful, return the new nonce + return reply.code(200).send({ + nonce: updatedData.nonce, + address: userProfile.address, + }); } catch (err) { console.error(err); // If an error occurs, return a 404 error with a message return reply - .code(403) + .code(500) .send({ error: "There was an issue updating the document" }); } }; @@ -41,7 +57,7 @@ export default async (fastify: FastifyInstance) => await fastify.route({ method: "PUT", url: "/users/:wallet", - onRequest: [fastify.authenticate], handler: handler(fastify), + preHandler: authenticateProfile(fastify), schema, }); diff --git a/carbonmark-api/src/routes/users/auth.ts b/carbonmark-api/src/routes/users/auth.ts new file mode 100644 index 0000000000..7f14775aa4 --- /dev/null +++ b/carbonmark-api/src/routes/users/auth.ts @@ -0,0 +1,48 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { verifyProfileSignature } from "../../utils/crypto.utils"; +import { getProfileByAddress } from "../../utils/helpers/users.utils"; + +const hasWalletParams = (params?: unknown): params is { wallet: string } => { + if (!params || !Object.hasOwn(params, "wallet")) return false; + return true; +}; + +const hasWalletBody = (body?: unknown): body is { wallet: string } => { + if (!body || !Object.hasOwn(body, "wallet")) return false; + return true; +}; + +// authenticate routes that require the SIGN_PROFILE_MESSAGE to be signed +export const authenticateProfile = + (fastify: FastifyInstance) => + async (request: FastifyRequest, reply: FastifyReply) => { + let address: string; + if (hasWalletParams(request.params)) { + address = request.params.wallet; + } else if (hasWalletBody(request.body)) { + address = request.body.wallet; + } else { + return reply.status(400).send({ + error: "Can't authenticate profile without wallet", + }); + } + + const profile = await getProfileByAddress({ + address, + firebase: fastify.firebase, + }); + const isAuthenticated = verifyProfileSignature({ + nonce: profile?.nonce, + expectedAddress: profile?.address || address, + signature: request.headers.authorization?.split(" ")[1] || "", + }); + + if (!isAuthenticated) { + return reply.status(403).send({ + error: "Unauthorized profile operation", + }); + } + + // pass to handler to avoid querying again + request.userProfile = profile; + }; diff --git a/carbonmark-api/src/routes/users/post.schema.ts b/carbonmark-api/src/routes/users/post.schema.ts index 926762f3e2..bc4124f90a 100644 --- a/carbonmark-api/src/routes/users/post.schema.ts +++ b/carbonmark-api/src/routes/users/post.schema.ts @@ -1,18 +1,21 @@ -import { Static, Type } from "@sinclair/typebox"; -import { Nullable } from "../../models/Utility.model"; +import { Type } from "@sinclair/typebox"; +import { VALID_ADDRESS_REGEX } from "../../app.constants"; export const RequestBody = Type.Object( { handle: Type.String({ minLength: 3, maxLength: 24 }), username: Type.String({ minLength: 2 }), - wallet: Type.String({ minLength: 26, maxLength: 64 }), - description: Nullable(Type.String({ maxLength: 500 })), - profileImgUrl: Nullable(Type.String()), + wallet: Type.String({ pattern: VALID_ADDRESS_REGEX.source }), + description: Type.Optional(Type.String({ maxLength: 500 })), + profileImgUrl: Type.Optional(Type.String()), }, { required: ["handle", "username", "wallet", "description"] } ); -export type CreateUserResponse = Static; +export const ResponseBody = Type.Object({ + address: Type.String({ pattern: VALID_ADDRESS_REGEX.source }), + nonce: Type.Number(), +}); export const schema = { summary: "Create user profile", @@ -22,7 +25,7 @@ export const schema = { 200: { content: { "application/json": { - schema: RequestBody, + schema: ResponseBody, }, }, }, diff --git a/carbonmark-api/src/routes/users/post.ts b/carbonmark-api/src/routes/users/post.ts index 7b3d0fbf66..b6094b594a 100644 --- a/carbonmark-api/src/routes/users/post.ts +++ b/carbonmark-api/src/routes/users/post.ts @@ -1,6 +1,8 @@ import { Static } from "@sinclair/typebox"; import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; -import { CreateUserResponse, RequestBody, schema } from "./post.schema"; +import { UserProfile } from "../../models/UserProfile.model"; +import { authenticateProfile } from "./auth"; +import { RequestBody, ResponseBody, schema } from "./post.schema"; const handler = (fastify: FastifyInstance) => async function ( @@ -11,69 +13,62 @@ const handler = (fastify: FastifyInstance) => const { wallet, username, handle, description, profileImgUrl } = request.body; - const createData = { + const userProfile = request.userProfile; + if (userProfile) { + return reply.status(409).send({ + error: "A user record already exists for this address", + }); + } + + const createData: UserProfile = { handle: handle.toLowerCase(), createdAt: Date.now(), updatedAt: Date.now(), address: wallet.toLowerCase(), username, - description, - profileImgUrl, + description: description || "", + profileImgUrl: profileImgUrl || "", + nonce: 1, }; - // Query the Firestore database for the user document with the specified wallet address - const user = await fastify.firebase - .firestore() - .collection("users") - .doc(wallet) - .get(); - - // If the user document exists, return a 403 error with a message - if (user.exists) { - return reply.code(403).send({ - error: "This wallet address is already registered!", - }); - } - - // Check if the handle already exists in our database - const usersRef = fastify.firebase.firestore().collection("users"); - - const userSnapshot = await usersRef - .where("handle", "==", handle.toLowerCase()) - .limit(1) - .get(); + try { + const db = fastify.firebase.firestore(); - if (!userSnapshot.empty) { - return reply.code(403).send({ - error: "A user with this handle is already registered!", - }); - } + // Check if the handle already exists in our database + const userSnapshot = await db + .collection("users") + .where("handle", "==", handle.toLowerCase()) + .limit(1) + .get(); - try { + if (!userSnapshot.empty) { + return reply.code(400).send({ + error: "A user with this handle is already registered!", + }); + } // Try creating a new user document with the specified data - await fastify.firebase - .firestore() - .collection("users") - .doc(wallet.toUpperCase()) - .set(createData); + await db.collection("users").doc(wallet.toUpperCase()).set(createData); - // If the document is successfully created, return the request body - return reply.code(200).send(request.body); + // If the document is successfully created, return the new nonce + return reply.code(200).send({ + nonce: createData.nonce, + address: createData.address, + }); } catch (err) { console.error(err); // If an error occurs, return the error in the response - return reply.code(403).send({ error: err }); + return reply.code(500).send({ error: err }); } }; export default async (fastify: FastifyInstance) => await fastify.route<{ Body: Static; - Reply: CreateUserResponse | { error: string }; + Reply: typeof ResponseBody | { error: string }; }>({ method: "POST", url: "/users", - onRequest: [fastify.authenticate], handler: handler(fastify), + preHandler: authenticateProfile(fastify), schema, }); diff --git a/carbonmark-api/src/utils/crypto.utils.ts b/carbonmark-api/src/utils/crypto.utils.ts index 65e5b67665..e04675cc65 100644 --- a/carbonmark-api/src/utils/crypto.utils.ts +++ b/carbonmark-api/src/utils/crypto.utils.ts @@ -1,11 +1,6 @@ -// This function generates a random nonce (number used once) by creating a random number, -// adding 1 to it, converting it to a base-36 string, and then removing the first two characters. - +import { ethers } from "ethers"; import { isString } from "lodash"; - -// The resulting string can be used as a unique identifier for a single-use purpose. -export const generateNonce = () => - (Math.random() + 1).toString(36).substring(2); +import { SIGN_PROFILE_MESSAGE } from "../app.constants"; const USDC_DECIMALS = 6; @@ -25,3 +20,24 @@ export const formatUSDC = (n: bigint | string): string => { const remainderString = remainder.toString().padStart(USDC_DECIMALS, "0"); // 10n -> "000010" return `${quotient}.${remainderString}`; }; + +/** + * Returns true if the message signer is the targetWallet + * Uses ECDSA under the hood to recover the signerWalletAddress + */ +export const verifyProfileSignature = (params: { + nonce?: number; + signature: string; + expectedAddress: string; +}) => { + if (!params.signature) return false; + // Backwards-compat: nonce may be undefined, append empty string + const expectedMessage = SIGN_PROFILE_MESSAGE + (params?.nonce || ""); + const signerWalletAddress = ethers.utils.verifyMessage( + expectedMessage, + params.signature + ); + return ( + signerWalletAddress.toLowerCase() === params.expectedAddress.toLowerCase() + ); +}; diff --git a/carbonmark-api/src/utils/firebase.utils.ts b/carbonmark-api/src/utils/firebase.utils.ts deleted file mode 100644 index 09c08b8319..0000000000 --- a/carbonmark-api/src/utils/firebase.utils.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { FirebaseInstance } from "../plugins/firebase"; - -export const getFirebaseUser = async (id: string, fb: FirebaseInstance) => - await fb.firestore().collection("users").doc(id).get(); diff --git a/carbonmark-api/src/utils/helpers/users.utils.ts b/carbonmark-api/src/utils/helpers/users.utils.ts index 11007920b6..2fc1a19743 100644 --- a/carbonmark-api/src/utils/helpers/users.utils.ts +++ b/carbonmark-api/src/utils/helpers/users.utils.ts @@ -2,22 +2,9 @@ import { app } from "firebase-admin"; import { chunk } from "lodash"; import { UserProfile } from "../../models/UserProfile.model"; -/** - * This function retrieves a user by their wallet address from the Firestore database. - */ -export const getProfileByAddress = async (params: { - firebase: app.App; - address: string; -}): Promise => { - const doc = await params.firebase - .firestore() - .collection("users") - .doc(params.address.toUpperCase()) - .get(); - - if (!doc.exists) return null; - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- known type - return formatProfile(doc.data() as UserProfile); +export const isUserProfile = (doc?: unknown): doc is UserProfile => { + if (!doc || !Object.hasOwn(doc, "createdAt")) return false; + return true; }; /** @@ -45,8 +32,8 @@ export const getUserProfilesByIds = async (params: { .flatMap((s) => s.docs) .forEach((d) => { if (!d.exists) return; - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- known - const profile = d.data() as UserProfile; + const profile = d.data(); + if (!isUserProfile(profile)) return null; UserProfileMap.set(profile.address, formatProfile(profile)); }); return UserProfileMap; @@ -67,11 +54,30 @@ export const getProfileByHandle = async (params: { .get(); if (snapshot.empty) return null; - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- apply known type - const profile = snapshot.docs.at(0)?.data() as UserProfile | undefined; + const profile = snapshot.docs.at(0)?.data(); + if (!isUserProfile(profile)) return null; return profile ? formatProfile(profile) : null; }; -const formatProfile = (profile: UserProfile): UserProfile => { + +/** + * This function retrieves a user by their handle from the Firestore database. + */ +export const getProfileByAddress = async (params: { + firebase: app.App; + address: string; +}): Promise => { + const userDocRef = await params.firebase + .firestore() + .collection("users") + .doc(params.address.toUpperCase()) + .get(); + if (!userDocRef.exists) return null; + const userDoc = userDocRef.data(); + if (!isUserProfile(userDoc)) return null; + return formatProfile(userDoc); +}; + +export const formatProfile = (profile: UserProfile): UserProfile => { return { ...profile, createdAt: Math.floor(profile.createdAt / 1000), diff --git a/carbonmark-api/test/flows/authentication.test.ts b/carbonmark-api/test/flows/authentication.test.ts deleted file mode 100644 index 0d6d8594db..0000000000 --- a/carbonmark-api/test/flows/authentication.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ethers } from "ethers"; -import { FastifyInstance } from "fastify"; -import { build } from "../helper"; -import { DEV_URL } from "../test.constants"; - -// The private key of the account to sign the message with -const MOCK_PRIVATE_KEY = - "0x0123456789012345678901234567890123456789012345678901234567890123"; - -// Assuming you have a connected provider -const wallet = new ethers.Wallet(MOCK_PRIVATE_KEY); - -describe("Authentication flow", () => { - let app: FastifyInstance; - - // Setup the server - afterEach(async () => await app.close()); - beforeEach(async () => { - app = await build(); - }); - - test("should authenticate user", async () => { - /** 1. Login */ - const { nonce } = await app - .inject({ - method: "POST", - url: `${DEV_URL}/login`, - body: { - wallet: wallet.address, - }, - }) - .then((d) => d.json()); - - /** 2. Build and sign the authentication message */ - const message = process.env.AUTHENTICATION_MESSAGE + nonce; - const signed_message = await wallet.signMessage(message); - - /** 3. Request JRPC Token */ - const response = await app - .inject({ - method: "POST", - url: `${DEV_URL}/login/verify`, - body: { - wallet: wallet.address, - signature: signed_message, - }, - }) - .then((d) => d.body); - - const token = JSON.parse(response).token; - - expect(typeof token).toBe("string"); - }); - - test("should fail authentication for invalid user", async () => { - /** 1. Login */ - const { nonce } = await app - .inject({ - method: "POST", - url: `${DEV_URL}/login`, - body: { - wallet: wallet.address, - }, - }) - .then((d) => d.json()); - - /** 2. Build and sign the authentication message */ - const message = process.env.AUTHENTICATION_MESSAGE + nonce; - const signed_message = await wallet.signMessage(message); - - /** 3. Request JRPC Token with invalid wallet */ - const response = await app - .inject({ - method: "POST", - url: `${DEV_URL}/login/verify`, - body: { - wallet: "0xInvalidWalletAddress", - signed_message, - }, - }) - .then((d) => d.json()); - - expect(response.error).toBeTruthy(); - }); - - test("should fail login for invalid address", async () => { - /** 1. Login */ - const response = await app - .inject({ - method: "POST", - url: `${DEV_URL}/login`, - body: { - wallet: "0xInvalidWalletAddress", - }, - }) - .then((d) => d.json()); - - expect(response.error).toBeTruthy(); - }); -}); diff --git a/carbonmark-api/test/helper.ts b/carbonmark-api/test/helper.ts index 38549fea71..d36984ab5a 100644 --- a/carbonmark-api/test/helper.ts +++ b/carbonmark-api/test/helper.ts @@ -1,40 +1,30 @@ import Fastify, { FastifyInstance } from "fastify"; import nock from "nock"; import app from "../src/app"; +import { mockFirestore } from "./test.utils"; + type Args = { allowNetworkRequest?: boolean; logger?: boolean; }; -let fastify: FastifyInstance; -afterEach(async () => { - await fastify?.close(); -}); +beforeAll(() => mockFirestore()); +afterAll(() => mockFirestore()); /** * This function is used to build and prepare a Fastify instance for use. * It also cleans up any network connections made by nock. - * - * @returns {FastifyInstance} The prepared Fastify instance. */ -export async function build(args?: Args) { +export async function build(args?: Args): Promise { try { - // Create a new Fastify instance - fastify = Fastify({ logger: args?.logger }); - - // Register the application with the Fastify instance - await fastify.register(app); - - // Wait for Fastify to be ready - await fastify.ready(); + const fastify = Fastify({ logger: args?.logger }); + await app(fastify, {}); - // Clean all nocks + if (!args?.allowNetworkRequest) nock.disableNetConnect(); nock.cleanAll(); - // Disable all network connections made by nock - if (!args?.allowNetworkRequest) nock.disableNetConnect(); + await fastify.ready(); - // Return the prepared Fastify instance return fastify; } catch (e) { console.warn("Setup failed to build. Try npm run build?"); diff --git a/carbonmark-api/test/routes/projects/get.test.ts b/carbonmark-api/test/routes/projects/get.test.ts index 968ee05d89..e44f6148a0 100644 --- a/carbonmark-api/test/routes/projects/get.test.ts +++ b/carbonmark-api/test/routes/projects/get.test.ts @@ -417,15 +417,14 @@ describe("GET /projects", () => { test("Subgraph fields should be sanitised", async () => { mockMarketplaceProjects(); mockDigitalCarbonProjects(); - const modifiedCmsProject = cloneDeep(fixtures.cms.cmsProject); - set(modifiedCmsProject, "country", " lots-of-spaces "); + set(modifiedCmsProject, "country", "China"); /**@todo add other fields */ mockCms({ projects: [modifiedCmsProject] }); const projects: Project[] = await mock_fetch(fastify, "/projects"); expect(projects.length).toBe(2); - expect(projects.at(0)?.country).toBe("lots-of-spaces"); + expect(projects.at(0)?.country).toBe("China"); }); test.todo("Same asset in multiple pools and listings"); diff --git a/carbonmark-api/test/routes/retirements/[id]/get.test.ts b/carbonmark-api/test/routes/retirements/[id]/get.test.ts index a11baa5638..378db5b952 100644 --- a/carbonmark-api/test/routes/retirements/[id]/get.test.ts +++ b/carbonmark-api/test/routes/retirements/[id]/get.test.ts @@ -1,7 +1,6 @@ import { FastifyInstance } from "fastify"; import { build } from "../../../helper"; import { DEV_URL } from "../../../test.constants"; -import { disableAuth } from "../../../test.utils"; import { expectedTransformedRetirement, mockDatabaseProfile, @@ -10,7 +9,6 @@ import { describe("GET /retirements/:id", () => { let fastify: FastifyInstance; - disableAuth(); mockDatabaseProfile(); // Setup the server beforeEach(async () => { diff --git a/carbonmark-api/test/routes/retirements/get.test.mocks.ts b/carbonmark-api/test/routes/retirements/get.test.mocks.ts index b07cfac60e..94d66dfbad 100644 --- a/carbonmark-api/test/routes/retirements/get.test.mocks.ts +++ b/carbonmark-api/test/routes/retirements/get.test.mocks.ts @@ -3,7 +3,7 @@ import nock from "nock"; import { Retire } from "../../../src/.generated/types/digitalCarbon.types"; import { GRAPH_URLS } from "../../../src/app.constants"; import { fixtures } from "../../fixtures"; -import { mockFirebase } from "../../test.utils"; +import { mockFirestore } from "../../test.utils"; const mockProfile = fixtures.firebase.profile; const mockRetirement = fixtures.digitalCarbon.retirement; @@ -35,7 +35,7 @@ export const expectedTransformedRetirement = { }, }; export const mockDatabaseProfile = () => - mockFirebase({ + mockFirestore({ get: jest.fn(() => ({ exists: true, data: () => mockProfile, diff --git a/carbonmark-api/test/routes/users/get.test.ts b/carbonmark-api/test/routes/users/get.test.ts index 39c328e398..fc2d561e79 100644 --- a/carbonmark-api/test/routes/users/get.test.ts +++ b/carbonmark-api/test/routes/users/get.test.ts @@ -1,4 +1,5 @@ import { FastifyInstance } from "fastify"; +import { omit } from "lodash"; import nock from "nock"; import { aListing, @@ -13,28 +14,28 @@ import { MOCK_ADDRESS, MOCK_USER_PROFILE, } from "../../test.constants"; -import { disableAuth, mockFirebase } from "../../test.utils"; +import { mockFirestore } from "../../test.utils"; + +let app: FastifyInstance; +beforeAll(async () => { + mockFirestore({ + exists: true, + empty: false, + data: () => MOCK_USER_PROFILE, + docs: [{ data: () => MOCK_USER_PROFILE }], + }); + app = await build(); +}); -describe("GET /users/[walletOrHandle]", () => { - let app: FastifyInstance; - // const holding = aHolding({ token: aToken({ decimals: 8 }) }); - - beforeEach(async () => { - disableAuth(); - // Mock Firebase with a user - mockFirebase({ - get: jest.fn(() => ({ - exists: true, - data: () => MOCK_USER_PROFILE, - docs: [{ data: () => MOCK_USER_PROFILE }], - })), - }); - app = await build(); +afterAll(async () => { + await app.close(); +}); +describe("GET /users/[walletOrHandle]", () => { + beforeEach(() => { nock(GRAPH_URLS["polygon"].marketplace) .post("", (body) => body.query.includes("getUserByWallet")) .reply(200, { data: { users: [aUser()] } }); - nock(GRAPH_URLS["polygon"].assets) .post("") .reply(200, { @@ -62,7 +63,7 @@ describe("GET /users/[walletOrHandle]", () => { const actual_response = await response.json(); expect(response.statusCode).toBe(200); - expect(EXPECTED_USER_RESPONSE).toEqual(actual_response); + expect(actual_response).toEqual(EXPECTED_USER_RESPONSE); }); test("by handle", async () => { @@ -70,51 +71,67 @@ describe("GET /users/[walletOrHandle]", () => { method: "GET", url: `${DEV_URL}/users/${MOCK_USER_PROFILE.handle}`, // use handle instead of wallet address }); - const actual_response = await response?.json(); expect(response?.statusCode).toBe(200); expect(actual_response).toEqual(EXPECTED_USER_RESPONSE); // check if the returned handle is correct }); - test("with invalid handle", async () => { - //Remove existing mocks - jest.unmock("firebase-admin"); - jest.unmock("firebase-admin/app"); - - //Return no users - mockFirebase({ get: jest.fn(() => ({ empty: true })) }); - - /** We need to close the existing server */ - app.close(); - /** And create a new one with updated firebase mock */ + test("without nonce for backwards compat", async () => { + mockFirestore({ + exists: true, + empty: false, + docs: [ + { + data: () => ({ + ...omit(MOCK_USER_PROFILE, "nonce"), + }), + }, + ], + }); app = await build(); + nock(GRAPH_URLS["polygon"].marketplace) + .post("", (body) => body.query.includes("getUserByWallet")) + .reply(200, { data: { users: [aUser()] } }); + nock(GRAPH_URLS["polygon"].assets) + .post("") + .reply(200, { + data: { + accounts: [], + }, + }); + const response = await app.inject({ + method: "GET", + url: `${DEV_URL}/users/${MOCK_USER_PROFILE.handle}`, // use handle instead of wallet address + }); + const expected_response = { + ...omit(EXPECTED_USER_RESPONSE, ["nonce"]), + }; + const actual_response = await response?.json(); + expect(response?.statusCode).toBe(200); + expect(actual_response).toEqual(expected_response); // check if the returned handle is correct + }); + test("with invalid handle", async () => { + mockFirestore({ + exists: false, + empty: true, + }); const response = await app.inject({ method: "GET", url: `${DEV_URL}/users/invalid_address`, // use an invalid wallet address }); - expect(response.statusCode).toBe(404); // expect a 404 Not Found status code }); - test("with invalid address or handle", async () => { - //Remove existing mocks - jest.unmock("firebase-admin"); - jest.unmock("firebase-admin/app"); - - //Return no users - mockFirebase({ get: jest.fn(() => ({ exists: false })) }); - - /** We need to close the existing server */ - app.close(); - /** And create a new one with updated firebase mock */ - app = await build(); - + test("with invalid address", async () => { + mockFirestore({ + exists: false, + empty: true, + }); const response = await app.inject({ method: "GET", - url: `${DEV_URL}/users/${MOCK_ADDRESS}`, // use an invalid handle + url: `${DEV_URL}/users/0xbadaddress`, }); - expect(response.statusCode).toBe(404); // expect a 404 Not Found status code }); @@ -130,8 +147,6 @@ describe("GET /users/[walletOrHandle]", () => { data: { accounts: [] }, }); - app = await build(); - const response = await app.inject({ method: "GET", url: `${DEV_URL}/users/${MOCK_ADDRESS}?network=mumbai`, diff --git a/carbonmark-api/test/routes/users/post.test.ts b/carbonmark-api/test/routes/users/post.test.ts index 072a84bee7..bec9164012 100644 --- a/carbonmark-api/test/routes/users/post.test.ts +++ b/carbonmark-api/test/routes/users/post.test.ts @@ -1,49 +1,201 @@ +import { Wallet } from "ethers"; import { FastifyInstance } from "fastify"; +import { SIGN_PROFILE_MESSAGE } from "../../../src/app.constants"; import { build } from "../../helper"; -import { DEV_URL, MOCK_ADDRESS } from "../../test.constants"; -import { disableAuth, mockFirebase } from "../../test.utils"; +import { DEV_URL } from "../../test.constants"; +import { mockFirestore } from "../../test.utils"; -describe("POST /User", () => { - let app: FastifyInstance; +const wallet = Wallet.createRandom(); - beforeEach(async () => { - disableAuth(); - app = await build(); +let app: FastifyInstance; +beforeAll(async () => { + mockFirestore({ + empty: true, }); + app = await build(); +}); - test("should block ethereum address as handle when creating a user", async () => { - mockFirebase({ get: jest.fn(() => ({ empty: true })) }); +afterAll(async () => { + await app.close(); +}); +describe("POST /User", () => { + test("Schema prevents handles > 24 chars", async () => { const response = await app.inject({ method: "POST", url: `${DEV_URL}/users`, body: { - handle: MOCK_ADDRESS.slice(0, 48), - wallet: MOCK_ADDRESS, - username: "blah", - description: "blah", + handle: "1234567890123456789012345", + wallet: wallet.address, + username: "testusername", + description: "testdescription", }, }); - expect(response.statusCode).toBe(400); expect(response.body).toContain( "body/handle must NOT have more than 24 characters" ); }); - test("should allow 0x names (not addresses)", async () => { - mockFirebase({ get: jest.fn(() => ({ exists: false })) }); + test("Schema prevents handles < 3 chars", async () => { + const response = await app.inject({ + method: "POST", + url: `${DEV_URL}/users`, + body: { + handle: "12", + wallet: wallet.address, + username: "testusername", + description: "testdescription", + }, + }); + expect(response.statusCode).toBe(400); + expect(response.body).toContain( + "body/handle must NOT have fewer than 3 characters" + ); + }); + + test("Schema prevents usernames < 2 chars", async () => { + const response = await app.inject({ + method: "POST", + url: `${DEV_URL}/users`, + body: { + handle: "12345", + wallet: wallet.address, + username: "1", + description: "testdescription", + }, + }); + expect(response.statusCode).toBe(400); + expect(response.body).toContain( + "body/username must NOT have fewer than 2 characters" + ); + }); + + test("Allow 0x names (not addresses)", async () => { + const message = SIGN_PROFILE_MESSAGE; // no nonce + const signature = await wallet.signMessage(message); + const response = await app.inject({ + method: "POST", + url: `${DEV_URL}/users`, + body: { + handle: "0xmycoolhandle", + wallet: wallet.address, + username: "testusername", + description: "testdescription", + }, + headers: { + Authorization: `Bearer ${signature}`, + }, + }); + expect(response.statusCode).toBe(200); + }); + + test("disallow unsigned POST", async () => { + const response = await app.inject({ + method: "POST", + url: `${DEV_URL}/users`, + body: { + handle: "0xmycoolhandle", + wallet: wallet.address, + username: "blah", + description: "blah", + }, + }); + expect(response.statusCode).toBe(403); + }); + + test("disallow wallet mismatch", async () => { + const mockSet = jest.fn(); + mockFirestore({ + empty: true, + set: mockSet, + }); + const wallet2 = Wallet.createRandom(); + const message = SIGN_PROFILE_MESSAGE; // no nonce + const signature = await wallet2.signMessage(message); // BAD SIGNATURE + const response = await app.inject({ + method: "POST", + url: `${DEV_URL}/users`, + body: { + handle: "0xmycoolhandle", + wallet: wallet.address, + username: "blah", + description: "blah", + }, + headers: { + Authorization: `Bearer ${signature}`, + }, + }); + expect(response.statusCode).toBe(403); + }); + test("should set nonce to 1 and write to firestore", async () => { + const mockSet = jest.fn(); + mockFirestore({ + empty: true, + set: mockSet, + }); + const message = SIGN_PROFILE_MESSAGE; + const signature = await wallet.signMessage(message); const response = await app.inject({ method: "POST", url: `${DEV_URL}/users`, body: { handle: "0xmycoolhandle", - wallet: MOCK_ADDRESS, + wallet: wallet.address, username: "blah", description: "blah", + profileImgUrl: "", + }, + headers: { + Authorization: `Bearer ${signature}`, + }, + }); + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body).nonce).toBe(1); + expect(mockSet).toHaveBeenCalledWith({ + handle: "0xmycoolhandle", + address: wallet.address.toLowerCase(), + description: "blah", + username: "blah", + updatedAt: expect.any(Number), + createdAt: expect.any(Number), + profileImgUrl: "", + nonce: 1, + }); + }); + + test("Description and profileImgUrl are optional", async () => { + const mockSet = jest.fn(); + mockFirestore({ + empty: true, + set: mockSet, + }); + const message = SIGN_PROFILE_MESSAGE; + const signature = await wallet.signMessage(message); + const response = await app.inject({ + method: "POST", + url: `${DEV_URL}/users`, + body: { + handle: "0xmycoolhandle", + wallet: wallet.address, + username: "blah", + }, + headers: { + Authorization: `Bearer ${signature}`, }, }); expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body).nonce).toBe(1); + expect(mockSet).toHaveBeenCalledWith({ + handle: "0xmycoolhandle", + address: wallet.address.toLowerCase(), + username: "blah", + updatedAt: expect.any(Number), + createdAt: expect.any(Number), + nonce: 1, + description: "", + profileImgUrl: "", + }); }); }); diff --git a/carbonmark-api/test/routes/users/put.test.ts b/carbonmark-api/test/routes/users/put.test.ts index 875009d0cc..8a81f26dd1 100644 --- a/carbonmark-api/test/routes/users/put.test.ts +++ b/carbonmark-api/test/routes/users/put.test.ts @@ -1,27 +1,201 @@ +import { Wallet } from "ethers"; +import { FastifyInstance } from "fastify"; +import { SIGN_PROFILE_MESSAGE } from "../../../src/app.constants"; import { build } from "../../helper"; -import { DEV_URL, MOCK_ADDRESS } from "../../test.constants"; -import { disableAuth, mockFirebase } from "../../test.utils"; +import { DEV_URL } from "../../test.constants"; +import { mockFirestore } from "../../test.utils"; + +let app: FastifyInstance; + +const wallet = Wallet.createRandom(); +const nonce = 1; + +beforeAll(async () => { + mockFirestore({ + exists: true, + data: () => ({ + createdAt: 123, + address: wallet.address.toLowerCase(), + nonce: 1, + username: "testusername", + description: "testdescription", + handle: "testhandle", + profileImgUrl: null, + }), + }); + app = await build(); +}); + +afterAll(async () => { + await app.close(); +}); describe("PUT /User", () => { - beforeAll(() => { - disableAuth(); + test("allow signed updates, increment nonce", async () => { + const message = SIGN_PROFILE_MESSAGE + `${nonce}`; + const signature = await wallet.signMessage(message); + const response = await app.inject({ + method: "PUT", + url: `${DEV_URL}/users/0x1234`, + body: { + username: "newusername", + description: "newdescription", + profileImgUrl: "https://example.com/image.png", + }, + headers: { + Authorization: `Bearer ${signature}`, + }, + }); + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body).nonce).toBe(2); + }); + + test("Ignores handle and wallet changes, applies profile updates", async () => { + const message = SIGN_PROFILE_MESSAGE + `${nonce}`; + const signature = await wallet.signMessage(message); + const mockUpdate = jest.fn(); + mockFirestore({ + exists: true, + update: mockUpdate, + data: () => ({ + createdAt: 123, + address: wallet.address.toLowerCase(), + nonce: 1, + }), + }); + const response = await app.inject({ + method: "PUT", + url: `${DEV_URL}/users/0x1234`, + body: { + username: "newusername", + description: "newdescription", + profileImgUrl: "https://example.com/image.png", + }, + headers: { + Authorization: `Bearer ${signature}`, + }, + }); + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body).nonce).toBe(2); + expect(mockUpdate).toHaveBeenCalledWith({ + username: "newusername", + description: "newdescription", + updatedAt: expect.any(Number), + profileImgUrl: "https://example.com/image.png", + nonce: 2, + }); + }); + + test("Reject username <2 chars", async () => { + const message = SIGN_PROFILE_MESSAGE + `${nonce}`; + const signature = await wallet.signMessage(message); + const mockUpdate = jest.fn(); + mockFirestore({ + exists: true, + update: mockUpdate, + data: () => ({ + createdAt: 123, + address: wallet.address.toLowerCase(), + nonce: 1, + }), + }); + const response = await app.inject({ + method: "PUT", + url: `${DEV_URL}/users/0x1234`, + body: { + username: "z", + }, + headers: { + Authorization: `Bearer ${signature}`, + }, + }); + expect(response.statusCode).toBe(400); + expect(mockUpdate).not.toHaveBeenCalled(); }); - test("should allow updates", async () => { - // Mock Firebase with no users - mockFirebase({ get: jest.fn().mockReturnValue({ empty: true }) }); - const app = await build(); + test("accepts empty string for description, profileImgUrl", async () => { + const message = SIGN_PROFILE_MESSAGE + `${nonce}`; + const signature = await wallet.signMessage(message); + const mockUpdate = jest.fn(); + mockFirestore({ + exists: true, + update: mockUpdate, + data: () => ({ + createdAt: 123, + address: wallet.address.toLowerCase(), + nonce: 1, + }), + }); + const response = await app.inject({ + method: "PUT", + url: `${DEV_URL}/users/0x12345`, + body: { + username: "me", + description: "", + profileImgUrl: "", + }, + headers: { + Authorization: `Bearer ${signature}`, + }, + }); + expect(response.statusCode).toBe(200); + expect(JSON.parse(response.body).nonce).toBe(2); + expect(mockUpdate).toHaveBeenCalledWith({ + username: "me", + description: "", + updatedAt: expect.any(Number), + profileImgUrl: "", + nonce: 2, + }); + }); + test("should disallow unsigned updates", async () => { const response = await app.inject({ method: "PUT", - url: `${DEV_URL}/users/${MOCK_ADDRESS}`, + url: `${DEV_URL}/users/0x1234`, body: { - handle: MOCK_ADDRESS.slice(0, 24), - wallet: MOCK_ADDRESS, username: "blah", description: "blah", }, + headers: { + // Authorization: `Bearer ${signature}`, + }, }); - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(403); + }); + + test("should disallow nonce mismatch", async () => { + const message = SIGN_PROFILE_MESSAGE + `${2}`; // BAD NONCE + const signature = await wallet.signMessage(message); + const response = await app.inject({ + method: "PUT", + url: `${DEV_URL}/users/0x1234`, + body: { + username: "blah", + description: "blah", + }, + headers: { + Authorization: `Bearer ${signature}`, + }, + }); + expect(response.statusCode).toBe(403); + }); + + test("should disallow other signers", async () => { + const wallet2 = Wallet.createRandom(); + const message = SIGN_PROFILE_MESSAGE + `${nonce}`; + const signature = await wallet2.signMessage(message); // BAD SIGNER + const response = await app.inject({ + method: "PUT", + url: `${DEV_URL}/users/0x1234`, + body: { + username: "blah", + description: "blah", + }, + headers: { + Authorization: `Bearer ${signature}`, + }, + }); + expect(response.statusCode).toBe(403); }); }); diff --git a/carbonmark-api/test/test.constants.ts b/carbonmark-api/test/test.constants.ts index c2a8466dfa..202f2d68d8 100644 --- a/carbonmark-api/test/test.constants.ts +++ b/carbonmark-api/test/test.constants.ts @@ -59,7 +59,8 @@ export const MOCK_USER_PROFILE: UserProfile = { handle: "SomeHandle", updatedAt: new Date("2023-11-11T15:05:08Z").getTime(), username: "someusername", - profileImgUrl: null, + profileImgUrl: "https://example.com/image.jpg", + nonce: 1, }; export const EXPECTED_USER_RESPONSE = { diff --git a/carbonmark-api/test/test.utils.ts b/carbonmark-api/test/test.utils.ts index f6f2a651f9..88186e88f5 100644 --- a/carbonmark-api/test/test.utils.ts +++ b/carbonmark-api/test/test.utils.ts @@ -1,12 +1,32 @@ import { FastifyInstance, InjectOptions } from "fastify"; import { DEV_URL } from "./test.constants"; +const firestoreMethods = { + app: jest.fn().mockReturnThis(), + collection: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockReturnThis(), + doc: jest.fn().mockReturnThis(), + settings: jest.fn(), + docs: [], + set: jest.fn(), + update: jest.fn(), + data: jest.fn(), + exists: false, + empty: true, +}; + +const firestore = jest.fn(() => { + return firestoreMethods; +}); + /** * Mocks the Firebase Admin SDK for testing purposes. * * @param {any} overrides - Optional. An object containing methods to override the default mock methods. */ -export function mockFirebase(overrides?: any) { +export function mockFirestore(overrides?: any) { jest.resetModules(); jest.mock("firebase-admin/app", () => ({ @@ -14,31 +34,21 @@ export function mockFirebase(overrides?: any) { getApps: jest.fn().mockReturnValue([{}]), })); - jest.mock("firebase-admin", () => ({ - app: jest.fn(() => ({ - firestore: jest.fn(() => ({ - collection: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - get: jest.fn().mockReturnThis(), - doc: jest.fn().mockReturnThis(), - set: jest.fn(), - update: jest.fn(), - exists: false, - ...overrides, - })), - })), - })); -} - -//Disables the `bearer.ts` plugin -export function disableAuth() { - process.env.IGNORE_AUTH = "true"; -} + jest.mock("firebase-admin", () => { + return { + app: () => ({ + firestore, + }), + }; + }); -//Renable the `bearer.ts` plugin -export function enableAuth() { - delete process.env.IGNORE_AUTH; + // allows us to keep invoking `mockFirestore()` in our tests to apply new mock implementations + if (overrides) { + firestore.mockImplementation(() => ({ + ...firestoreMethods, + ...overrides, + })); + } } /** Wrap fastifies inject fn, throws internal server errors in order to fail tests and debug */ diff --git a/carbonmark-api/test/utils/crypto.utils.test.ts b/carbonmark-api/test/utils/crypto.utils.test.ts index 7f271243e2..4ab877a665 100644 --- a/carbonmark-api/test/utils/crypto.utils.test.ts +++ b/carbonmark-api/test/utils/crypto.utils.test.ts @@ -1,4 +1,9 @@ -import { formatUSDC } from "../../src/utils/crypto.utils"; +import { Wallet } from "ethers"; +import { SIGN_PROFILE_MESSAGE } from "../../src/app.constants"; +import { + formatUSDC, + verifyProfileSignature, +} from "../../src/utils/crypto.utils"; describe("formatUSDC", () => { test("formats strings", () => { @@ -22,3 +27,72 @@ describe("formatUSDC", () => { ); }); }); + +describe("verifyProfileSignature", () => { + test("Happy path", async () => { + const nonce = 123; + const wallet1 = Wallet.createRandom(); + const message = SIGN_PROFILE_MESSAGE + nonce; + const signature = await wallet1.signMessage(message); + expect( + verifyProfileSignature({ + nonce, + signature, + expectedAddress: wallet1.address, + }) + ).toBe(true); + }); + test("Returns false if nonce changes", async () => { + const nonce = 123; + const wallet1 = Wallet.createRandom(); + const message = SIGN_PROFILE_MESSAGE + nonce; + const signature = await wallet1.signMessage(message); + expect( + verifyProfileSignature({ + nonce: 124, // doesn't match what was signed above + signature, + expectedAddress: wallet1.address, + }) + ).toBe(false); + }); + test("Returns false if expected message changes", async () => { + const nonce = 123; + const wallet1 = Wallet.createRandom(); + const message = "oops_wrong_message" + nonce; + const signature = await wallet1.signMessage(message); + expect( + verifyProfileSignature({ + nonce, // doesn't match what was signed above + signature, + expectedAddress: wallet1.address, + }) + ).toBe(false); + }); + test("Returns false if signer is not expectedAddress", async () => { + const nonce = 123; + const wallet1 = Wallet.createRandom(); + const wallet2 = Wallet.createRandom(); + const message = SIGN_PROFILE_MESSAGE + nonce; + const signature = await wallet1.signMessage(message); + expect( + verifyProfileSignature({ + nonce, + signature, + expectedAddress: wallet2.address, + }) + ).toBe(false); + }); + test("Handles undefined nonces", async () => { + const nonce = undefined; + const wallet1 = Wallet.createRandom(); + const message = SIGN_PROFILE_MESSAGE; // undefined nonce is not appended + const signature = await wallet1.signMessage(message); + expect( + verifyProfileSignature({ + nonce, + signature, + expectedAddress: wallet1.address, + }) + ).toBe(true); + }); +}); diff --git a/carbonmark-api/test/utils/helpers/users.utils.test.ts b/carbonmark-api/test/utils/helpers/users.utils.test.ts new file mode 100644 index 0000000000..0e02d49b64 --- /dev/null +++ b/carbonmark-api/test/utils/helpers/users.utils.test.ts @@ -0,0 +1,10 @@ +import { isUserProfile } from "../../../src/utils/helpers/users.utils"; + +describe("isFirestoreUserDoc", () => { + test("Only checks for existence of createdAt property, does not assert shape", () => { + expect(isUserProfile({ createdAt: 0 })).toBe(true); + }); + test("Must have createdAt field present", () => { + expect(isUserProfile({ handle: "" })).toBe(false); + }); +}); diff --git a/package-lock.json b/package-lock.json index 4b4dee7075..0e1b94fb05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -588,20 +588,17 @@ }, "carbonmark-api": { "name": "@klimadao/carbonmark-api", - "version": "5.3.6", + "version": "6.0.0", "license": "ISC", "dependencies": { "@fastify/autoload": "^5.0.0", - "@fastify/cookie": "^8.3.0", "@fastify/cors": "^8.3.0", - "@fastify/jwt": "^6.7.1", "@fastify/rate-limit": "^8.0.1", "@fastify/response-validation": "^2.3.1", "@fastify/sensible": "^5.0.0", "@fastify/session": "^10.3.0", "@fastify/swagger": "^8.8.0", "@fastify/type-provider-typebox": "^3.1.0", - "@mgcrea/fastify-session": "^1.1.0", "@sanity/client": "^6.1.2", "@sinclair/typebox": "^0.28.5", "dotenv": "^16.1.4", @@ -4979,15 +4976,6 @@ "node": ">=14" } }, - "node_modules/@fastify/cookie": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-8.3.0.tgz", - "integrity": "sha512-P9hY9GO11L20TnZ33XN3i0bt+3x0zaT7S0ohAzWO950E9PB2xnNhLYzPFJIGFi5AVN0yr5+/iZhWxeYvR6KCzg==", - "dependencies": { - "cookie": "^0.5.0", - "fastify-plugin": "^4.0.0" - } - }, "node_modules/@fastify/cors": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-8.4.0.tgz", @@ -5015,18 +5003,6 @@ "fast-json-stringify": "^5.7.0" } }, - "node_modules/@fastify/jwt": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-6.7.1.tgz", - "integrity": "sha512-pvRcGeyF2H1U+HXaxlRBd6s1y99vbSZjhpxTWECIGIhMXKRxBTBSUPRF7LJGONlW1/pZstQ0/Dp/ZxBFlDuEnw==", - "dependencies": { - "@fastify/error": "^3.0.0", - "@lukeed/ms": "^2.0.0", - "fast-jwt": "^2.0.0", - "fastify-plugin": "^4.0.0", - "steed": "^1.1.3" - } - }, "node_modules/@fastify/rate-limit": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-8.0.3.tgz", @@ -8905,18 +8881,6 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, - "node_modules/@mgcrea/fastify-session": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@mgcrea/fastify-session/-/fastify-session-1.1.0.tgz", - "integrity": "sha512-Cm2BnwBszxfbGheGCX7qVDU/qDfTPyWKrezC7f5ssQLtylgaIavtxqp64K6atKxcSNWwje6cxXo2rxIZNHDB4g==", - "dependencies": { - "fastify-plugin": "^4.4.0", - "nanoid": "^3.3.4" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@motionone/animation": { "version": "10.16.3", "resolved": "https://registry.npmjs.org/@motionone/animation/-/animation-10.16.3.tgz", @@ -14351,22 +14315,6 @@ "safer-buffer": "~2.1.0" } }, - "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, - "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", - "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" - }, "node_modules/asn1js": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", @@ -18773,19 +18721,6 @@ "rfdc": "^1.2.0" } }, - "node_modules/fast-jwt": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-2.2.3.tgz", - "integrity": "sha512-ziANDWUZpgUyE+A8YAkauVnGa/XXJGEXC1H3qXAYnT8v4Et3EsC8Zuvw8ljiqDgRearw9Wy+Q/Miw5x1XmPJTA==", - "dependencies": { - "asn1.js": "^5.4.1", - "ecdsa-sig-formatter": "^1.0.11", - "mnemonist": "^0.39.5" - }, - "engines": { - "node": ">=14 <22" - } - }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", @@ -18866,17 +18801,6 @@ "fxparser": "src/cli/cli.js" } }, - "node_modules/fastfall": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz", - "integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==", - "dependencies": { - "reusify": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fastify": { "version": "4.24.3", "resolved": "https://registry.npmjs.org/fastify/-/fastify-4.24.3.tgz", @@ -19159,15 +19083,6 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, - "node_modules/fastparallel": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz", - "integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==", - "dependencies": { - "reusify": "^1.0.4", - "xtend": "^4.0.2" - } - }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -19176,15 +19091,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fastseries": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz", - "integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==", - "dependencies": { - "reusify": "^1.0.0", - "xtend": "^4.0.0" - } - }, "node_modules/faye-websocket": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", @@ -31072,18 +30978,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/steed": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz", - "integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==", - "dependencies": { - "fastfall": "^1.5.0", - "fastparallel": "^2.2.0", - "fastq": "^1.3.0", - "fastseries": "^1.7.0", - "reusify": "^1.0.0" - } - }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz",