Skip to content

Commit

Permalink
feature(locksmith): Natively generate Google Wallet pass for lock's k…
Browse files Browse the repository at this point in the history
…ey (#13895)

* set up necessary google credentials in configuration

* set up google wallet pass class and creation operations

* google authentication client

* operation: google wallet object creation for lock-associated key

* add necessary dependencies

* set up controller

* register native pass generation route

* fix typescript error

* fix typescript check arror

* set dummy google app cred example to improve dev ux

* add specific version for jsonwebtoken types dependency

* add yarn.lock

* Revert "add specific version for jsonwebtoken types dependency"

This reverts commit 686f756.

* Revert "add yarn.lock"

This reverts commit 70f92a1.

* update pass controller

* improve pass class and object operations for modularity

* tests for pass class and object operations

* test for pass controller

* remove unused import

* fix specific jsonwebtoken typed dependency

* update yarn.lock
  • Loading branch information
0xTxbi committed Jun 19, 2024
1 parent 546fdb2 commit 9b6ef30
Show file tree
Hide file tree
Showing 12 changed files with 721 additions and 0 deletions.
79 changes: 79 additions & 0 deletions locksmith/__tests__/controllers/v2/passController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import request from 'supertest'
import app from '../../app'
import config from '../../../src/config/config'
import { vi, expect } from 'vitest'

// Constants defining the parameters for the Google Wallet pass generation
const network = 11155111
const lockAddress = '0xe6314d3eD5590F2339C57B3011ADC27971D5EadB'
const keyId = '1'

// Mock configuration specific to Google Wallet API interaction
const mockGoogleConfig = {
googleApplicationCredentials: {
client_email: 'test@example.com',
private_key: 'test_private_key',
},
googleWalletIssuerID: 'issuer_id',
googleWalletClass: 'wallet_class',
}

// config with Google Wallet specifics
config.googleApplicationCredentials =
mockGoogleConfig.googleApplicationCredentials
config.googleWalletIssuerID = mockGoogleConfig.googleWalletIssuerID
config.googleWalletClass = mockGoogleConfig.googleWalletClass

// Mock implementations for dependent services
vi.mock('../../../src/operations/metadataOperations', () => ({
getLockMetadata: vi.fn(() => ({
name: 'Test Lock',
description:
'Keys minted from this test lock can be saved to your device as a Google Wallet pass.',
image:
'https://staging-locksmith.unlock-protocol.com/lock/0xe6314d3eD5590F2339C57B3011ADC27971D5EadB/icon',
attributes: [],
external_url: null,
})),
}))

// Mock for generating QR code URL
vi.mock('../../../src/utils/qrcode', () => ({
generateQrCodeUrl: vi.fn(() => 'https://example.com/qr'),
}))

// Mock for ensuring a wallet class exists or creating one
vi.mock(
'../../../src/operations/generate-pass/android/passClassService',
() => ({
getOrCreateWalletClass: vi.fn(() => 'issuer_id.wallet_class'),
})
)

// Mock for creating the wallet pass object
vi.mock(
'../../../src/operations/generate-pass/android/createPassObject',
() => ({
createWalletPassObject: vi.fn(() => 'https://example.com/pass'),
})
)

// tesst suite for Google Wallet pass generation
describe("Generate a Google Wallet pass for a lock's key", () => {
it('should return a response status of 200 and the URL to save the generated wallet pass', async () => {
// number of assertions expected
expect.assertions(2)

// execute the request to the Google Wallet pass generation endpoint
const generateGoogleWalletPassResponse = await request(app).get(
`/v2/pass/${network}/${lockAddress}/${keyId}/android`
)

// Assert the HTTP status code to be 200 (OK)
expect(generateGoogleWalletPassResponse.status).toBe(200)
// Assert the response body to contain the pass URL
expect(generateGoogleWalletPassResponse.body).toEqual({
passObjectUrl: 'https://example.com/pass',
})
})
})
115 changes: 115 additions & 0 deletions locksmith/__tests__/operations/walletPassClassOperations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { googleAuthClient } from '../../src/operations/generate-pass/android/googleAuthClient'
import {
createClass,
getOrCreateWalletClass,
} from '../../src/operations/generate-pass/android/passClassService'
import * as passClassService from '../../src/operations/generate-pass/android/passClassService'
import logger from '../../src/logger'
import { GaxiosResponse, GaxiosError } from 'gaxios'

// Mock the required modules
vi.mock('../../src/operations/generate-pass/android/googleAuthClient')
vi.mock('../../src/logger')

describe('Google Wallet Class Operations', () => {
beforeEach(() => {
vi.resetAllMocks()
vi.clearAllMocks()
})

describe('createClass', () => {
it('should create a new class and log success', async () => {
const mockClassId = 'testClassId'
const mockResponse: GaxiosResponse = {
data: { success: true },
status: 200,
statusText: 'OK',
headers: {},
config: {},
request: { responseURL: '' },
}

vi.mocked(googleAuthClient.request).mockResolvedValueOnce(mockResponse)

const result = await createClass(mockClassId)

expect(result).toEqual({ success: true })
})

it('should log and throw an error if class creation fails', async () => {
const mockClassId = 'testClassId'
const mockError = new Error('Request failed')
vi.mocked(googleAuthClient.request).mockRejectedValueOnce(mockError)

// Ensure createClass returns a promise
await expect(createClass(mockClassId)).rejects.toThrow(
'Error creating class'
)
})
})

describe('getOrCreateWalletClass', () => {
const mockClassId = 'testClassId'
it('should return existing class data if class exists', async () => {
const mockResponse: GaxiosResponse = {
data: { existing: true, id: mockClassId },
status: 200,
statusText: 'OK',
headers: {},
config: {},
request: { responseURL: '' },
}

vi.mocked(googleAuthClient.request).mockResolvedValueOnce(mockResponse)

const result = await getOrCreateWalletClass(mockClassId)

expect(result).toEqual(mockResponse.data.id)
})

it('should log the correct messages and proceed to create a new class when a 404 error occurs', async () => {
// Simulate a 404 error response from Google Wallet API
const mockError: Partial<GaxiosError> = {
response: {
data: null,
status: 404,
statusText: 'Not Found',
headers: {},
config: {},
request: { responseURL: '' },
},
}
vi.mocked(googleAuthClient.request).mockRejectedValueOnce(mockError)
// Mock the createClass function
const mockCreateClass = vi.fn().mockResolvedValueOnce({ success: true })
vi.spyOn(passClassService, 'createClass').mockImplementation(
mockCreateClass
)

// Spy on logger to check if the messages were logged
const loggerInfoSpy = vi.spyOn(logger, 'info')

// Call getOrCreateWalletClass to test its behavior
try {
await passClassService.getOrCreateWalletClass(mockClassId)
} catch (e) {
// handle
}

// Verify the logger was called with expected messages
expect(loggerInfoSpy).toHaveBeenCalledWith(
'Class does not exist, creating a new one...'
)
})

it('should log and throw an error if there is a problem checking class existence', async () => {
const mockError = new Error('Request failed')
vi.mocked(googleAuthClient.request).mockRejectedValueOnce(mockError)

await expect(getOrCreateWalletClass(mockClassId)).rejects.toThrow(
'Error checking class existence'
)
})
})
})
121 changes: 121 additions & 0 deletions locksmith/__tests__/operations/walletPassObjectOperations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import jwt from 'jsonwebtoken'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import config from '../../config/config'
import { createWalletPassObject } from '../../src/operations/generate-pass/android/createPassObject'

vi.mock('jsonwebtoken')
vi.mock('../../../config/config')
vi.mock('../../../logger')

describe('createWalletPassObject', () => {
// define mock configuration
const mockConfig = {
googleApplicationCredentials: {
client_email: 'test@example.com',
private_key: 'test_private_key',
},
}

beforeEach(() => {
config.googleApplicationCredentials =
mockConfig.googleApplicationCredentials
// Mock the jwt.sign method to return a fixed token
vi.mocked(jwt.sign).mockImplementation(() => 'mocked_token')
})

it('should create a valid save URL', async () => {
// Define input parameters for the createWalletPassObject function
const classId = 'testClassId'
const lockName = 'testLockName'
const networkName = 'testNetworkName'
const lockAddress = 'testLockAddress'
const keyId = 'testKeyId'
const qrCodeUrl = 'https://example.com/qrcode'

const saveUrl = await createWalletPassObject(
classId,
lockName,
networkName,
lockAddress,
keyId,
qrCodeUrl,
mockConfig.googleApplicationCredentials.client_email,
mockConfig.googleApplicationCredentials.private_key
)

// Assert that the returned save URL matches the expected URL
expect(saveUrl).toBe(`https://pay.google.com/gp/v/save/mocked_token`)

// Ensure jwt.sign was called with the correct parameters
expect(jwt.sign).toHaveBeenCalledWith(
{
iss: mockConfig.googleApplicationCredentials.client_email,
aud: 'google',
origins: [],
typ: 'savetowallet',
payload: {
genericObjects: [
{
id: `${classId}.${keyId}`,
classId: classId,
hexBackgroundColor: '#fffcf6',
logo: {
sourceUri: {
uri: 'https://raw.githubusercontent.com/unlock-protocol/unlock/master/design/logo/%C9%84nlock-Logo-monogram-black.png',
},
contentDescription: {
defaultValue: {
language: 'en-US',
value: 'LOGO_IMAGE_DESCRIPTION',
},
},
},
cardTitle: {
defaultValue: {
language: 'en-US',
value: lockName,
},
},
subheader: {
defaultValue: {
language: 'en-US',
value: 'Event',
},
},
header: {
defaultValue: {
language: 'en-US',
value: lockName,
},
},
textModulesData: [
{
id: 'id',
header: 'ID',
body: keyId,
},
{
id: 'network',
header: 'Network',
body: networkName,
},
{
id: 'lock_address',
header: 'Lock Address',
body: lockAddress,
},
],
barcode: {
type: 'QR_CODE',
value: qrCodeUrl,
alternateText: '',
},
},
],
},
},
'test_private_key',
{ algorithm: 'RS256' }
)
})
})
2 changes: 2 additions & 0 deletions locksmith/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"html-template-tag": "4.0.1",
"ics": "3.7.2",
"isomorphic-fetch": "3.0.0",
"jsonwebtoken": "9.0.2",
"lodash.isequal": "4.5.0",
"memory-cache-node": "1.4.0",
"multer": "1.4.5-lts.1",
Expand Down Expand Up @@ -100,6 +101,7 @@
"@types/cors": "2.8.17",
"@types/geoip-country": "4.0.2",
"@types/isomorphic-fetch": "0.0.39",
"@types/jsonwebtoken": "9.0.2",
"@types/multer": "1.4.11",
"@types/multer-s3": "3.0.3",
"@types/nock": "11.1.0",
Expand Down
37 changes: 37 additions & 0 deletions locksmith/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ interface DefenderRelayCredentials {
}
}

// Interface for Google API credentials
interface Credentials {
client_email: string
private_key: string
}

const defenderRelayCredentials: DefenderRelayCredentials = {}
Object.values(networks).forEach((network) => {
defenderRelayCredentials[network.id] = {
Expand All @@ -50,6 +56,27 @@ Object.values(networks).forEach((network) => {
}
})

/* Load and parse the Google application credentials from the environment variable GOOGLE_APPLICATION_CREDENTIALS.
// To obtain the GOOGLE_APPLICATION_CREDENTIALS, follow these steps:
// 1. Go to the Google Cloud Console: https://console.cloud.google.com/
// 2. Create or select a Google Cloud project.
// 3. Enable the Google Wallet API.
// 4. Navigate to IAM & Admin > Service Accounts.
// 5. Click "Create Service Account", enter a name and description, then click "Create" and "Continue".
// 7. Go to the "Keys" tab, click "Add Key" > "Create New Key", choose "JSON", and click "Create" to download the key file.
// 8. Set the environment variable to point to the key file:
// export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account-file.json"
// For more details, visit: https://cloud.google.com/docs/authentication/application-default-credentials
*/

// Dummy Google API credentials
const googleApplicationCredentials: Credentials = {
client_email: 'dummy-client-email@appspot.gserviceaccount.com',
private_key:
'-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkq...\n-----END PRIVATE KEY-----\n',
}

const config = {
isProduction,
database: {
Expand Down Expand Up @@ -86,6 +113,16 @@ const config = {
*/
gitcoinApiKey: process.env.GITCOIN_API_KEY,
gitcoinScorerId: process.env.GITCOIN_SCORER_ID,
googleApplicationCredentials,
// Google wallet Issuer ID
/* 1. Visit the Google Pay & Wallet Console: https://pay.google.com/gp/w/home/settings
2. Sign in with your Google account and complete the registration process.
3. Your Issuer ID will be displayed under "Issuer Info" in the console settings.
For more details, visit: https://codelabs.developers.google.com/add-to-wallet-web#3
*/
googleWalletIssuerID: process.env.GOOGLE_WALLET_API_ISSUER_ID,
// Google wallet class
googleWalletClass: process.env.GOOGLE_WALLET_API_CLASS,
logtailSourceToken: process.env.LOGTAIL,
sessionDuration: Number(process.env.SESSION_DURATION || 86400 * 60), // 60 days
requestTimeout: '25s',
Expand Down
Loading

0 comments on commit 9b6ef30

Please sign in to comment.