Skip to content

Commit

Permalink
fix: allow injecting email (#466)
Browse files Browse the repository at this point in the history
Makes `email` thing injectable for tests as opposed to overloading the
response type for testing purposes.

---------

Co-authored-by: Benjamin Goering <171782+gobengo@users.noreply.github.com>
  • Loading branch information
Gozala and gobengo committed Mar 1, 2023
1 parent e375ae4 commit b4b0173
Show file tree
Hide file tree
Showing 12 changed files with 136 additions and 45 deletions.
5 changes: 4 additions & 1 deletion .env.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ POSTMARK_TOKEN=secret
SENTRY_DSN=https://000000@0000000.ingest.sentry.io/00000
LOGTAIL_TOKEN=secret

# Set to false if you want to send emails with postmark instead
DEBUG_EMAIL=true

# CI secrets
SENTRY_TOKEN=secret
SENTRY_UPLOAD=false
SENTRY_UPLOAD=false
8 changes: 7 additions & 1 deletion packages/access-api/src/bindings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type {
SpaceTable,
} from '@web3-storage/access/types'
import type { Handler as _Handler } from '@web3-storage/worker-utils/router'
import { Email } from './utils/email.js'
import { Spaces } from './models/spaces.js'
import { Validations } from './models/validations.js'
import { loadConfig } from './config.js'
Expand All @@ -25,6 +24,11 @@ export interface AnalyticsEngineEvent {
readonly blobs?: Array<ArrayBuffer | string | null>
}

export interface Email {
sendValidation: ({ to: string, url: string }) => Promise<void>
send: ({ to: string, textBody: string, subject: string }) => Promise<void>
}

export interface Env {
// vars
ENV: string
Expand All @@ -41,6 +45,8 @@ export interface Env {
SENTRY_DSN: string
POSTMARK_TOKEN: string
POSTMARK_SENDER?: string

DEBUG_EMAIL?: string
LOGTAIL_TOKEN: string
// bindings
SPACES: KVNamespace
Expand Down
6 changes: 2 additions & 4 deletions packages/access-api/src/service/access-authorize.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,13 @@ export function accessAuthorizeProvider(ctx) {
await ctx.models.accounts.create(capability.nb.iss)

const url = `${ctx.url.protocol}//${ctx.url.host}/validate-email?ucan=${encoded}&mode=session`
// For testing
if (ctx.config.ENV === 'test') {
return url
}

await ctx.email.sendValidation({
to: Mailto.toEmail(capability.nb.iss),
url,
})

return {}
}
)
}
15 changes: 10 additions & 5 deletions packages/access-api/src/utils/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { loadConfig } from '../config.js'
import { Accounts } from '../models/accounts.js'
import { Spaces } from '../models/spaces.js'
import { Validations } from '../models/validations.js'
import { Email } from './email.js'
import * as Email from './email.js'
import { createUploadApiConnection } from '../service/upload-api-proxy.js'
import { DID } from '@ucanto/core'
import { DbDelegationsStorage } from '../models/delegations.js'
Expand All @@ -22,6 +22,14 @@ import { createD1Database } from './d1.js'
*/
export function getContext(request, env, ctx) {
const config = loadConfig(env)
const email =
config.ENV === 'test' ||
(config.ENV === 'dev' && env.DEBUG_EMAIL === 'true')
? Email.debug()
: Email.configure({
token: config.POSTMARK_TOKEN,
sender: config.POSTMARK_SENDER,
})

// Sentry
const sentry = new Toucan({
Expand Down Expand Up @@ -61,10 +69,7 @@ export function getContext(request, env, ctx) {
validations: new Validations(config.VALIDATIONS),
accounts: new Accounts(config.DB),
},
email: new Email({
token: config.POSTMARK_TOKEN,
sender: config.POSTMARK_SENDER,
}),
email,
uploadApi: createUploadApiConnection({
audience: DID.parse(config.DID).did(),
url: new URL(config.UPLOAD_API_URL),
Expand Down
48 changes: 47 additions & 1 deletion packages/access-api/src/utils/email.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
export const debug = () => new DebugEmail()

/**
* @param {{token:string, sender?:string}} opts
*/
export const configure = (opts) => new Email(opts)

export class Email {
/**
*
* @param {object} opts
* @param {string} opts.token
* @param {string} [opts.sender]
Expand Down Expand Up @@ -75,3 +81,43 @@ export class Email {
}
}
}

/**
* This is API compatible version of Email class that can be used during
* tests and debugging.
*/
export class DebugEmail {
/**
* Send validation email with ucan to register
*
* @param {{ to: string; url: string }} opts
*/
async sendValidation(opts) {
try {
// @ts-expect-error
globalThis.email.sendValidation(opts)
} catch {
// eslint-disable-next-line no-console
console.log('email.sendValidation', opts)
}
}

/**
* Send email
*
* @param {object} opts
* @param {string} opts.to
* @param {string} opts.textBody
* @param {string} opts.subject
*
*/
async send(opts) {
try {
// @ts-expect-error
globalThis.email.send(opts)
} catch {
// eslint-disable-next-line no-console
console.log('email.send', opts)
}
}
}
49 changes: 33 additions & 16 deletions packages/access-api/test/access-authorize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,22 @@ const t = assert
describe('access/authorize', function () {
/** @type {Awaited<ReturnType<typeof context>>} */
let ctx
/** @type {{to:string, url:string}[]} */
let outbox
beforeEach(async function () {
ctx = await context()
outbox = []
ctx = await context({
globals: {
email: {
/**
* @param {*} email
*/
sendValidation(email) {
outbox.push(email)
},
},
},
})
})

it('should issue ./update', async function () {
Expand All @@ -39,14 +53,14 @@ describe('access/authorize', function () {
})
.execute(conn)

if (!inv) {
return assert.fail('no output')
}
if (inv.error) {
return assert.fail(inv.message)
}

const url = new URL(inv)
const [email] = outbox
assert.notEqual(email, undefined, 'no email was sent')

const url = new URL(email.url)
const encoded =
/** @type {import('@web3-storage/access/types').EncodedDelegation<[import('@web3-storage/capabilities/types').AccessAuthorize]>} */ (
url.searchParams.get('ucan')
Expand Down Expand Up @@ -90,14 +104,16 @@ describe('access/authorize', function () {
})
.execute(conn)

if (!inv) {
return assert.fail('no output')
}
if (inv.error) {
return assert.fail(inv.message)
}

const url = new URL(inv)
const [email] = outbox
if (!inv) {
return assert.fail('no email was sent')
}

const url = new URL(email.url)
const encoded =
/** @type {import('@web3-storage/access/types').EncodedDelegation<[import('@web3-storage/capabilities/types').AccessAuthorize]>} */ (
url.searchParams.get('ucan')
Expand Down Expand Up @@ -126,10 +142,11 @@ describe('access/authorize', function () {
})
.execute(conn)

// @todo - this only returns string when ENV==='test'. Remove that env-specific behavior
assert.ok(typeof inv === 'string', 'invocation result is a string')
assert.equal(inv.error, undefined, 'invocation should not fail')
const [email] = outbox
assert.notEqual(email, undefined, 'email was sent')

const confirmEmailPostUrl = new URL(inv)
const confirmEmailPostUrl = new URL(email.url)
const confirmEmailPostResponse = await mf.dispatchFetch(
confirmEmailPostUrl,
{ method: 'POST' }
Expand Down Expand Up @@ -218,14 +235,14 @@ describe('access/authorize', function () {
})
.execute(conn)

if (!inv) {
return assert.fail('no output')
}
if (inv.error) {
return assert.fail(inv.message)
}

const url = new URL(inv)
const [email] = outbox
assert.notEqual(email, undefined, 'email was sent')

const url = new URL(email.url)
// click email url
await mf.dispatchFetch(url, { method: 'POST' })

Expand Down
7 changes: 5 additions & 2 deletions packages/access-api/test/helpers/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ function createBindings(env) {
}

/**
* @param {Partial<AccessApiBindings>} env - environment variables to use when configuring access-api. Defaults to process.env.
* @param {object} options
* @param {Partial<AccessApiBindings>} [options.env] - environment variables to use when configuring access-api. Defaults to process.env.
* @param {unknown} [options.globals] - globals passed into miniflare
*/
export async function context(env = {}) {
export async function context({ env = {}, globals } = {}) {
const bindings = createBindings({
...process.env,
...env,
Expand All @@ -57,6 +59,7 @@ export async function context(env = {}) {
d1Persist: undefined,
buildCommand: undefined,
log: new Log(LogLevel.ERROR),
...(globals ? { globals } : {}),
})

const binds = await mf.getBindings()
Expand Down
20 changes: 12 additions & 8 deletions packages/access-api/test/store-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ describe('proxy store/list invocations to upload-api', function () {
// and if it's present, the assertions will expect no error from the proxy or upstream
const privateKeyFromEnv = process.env.WEB3_STORAGE_PRIVATE_KEY
const { issuer, service, conn } = await context({
// this emulates the configuration for deployed environments,
// which will allow the access-api ucanto server to accept
// invocations where aud=web3storageDid
DID: web3storageDid,
// @ts-ignore
PRIVATE_KEY: privateKeyFromEnv ?? process.env.PRIVATE_KEY,
UPLOAD_API_URL: mockUpstreamUrl.toString(),
env: {
// this emulates the configuration for deployed environments,
// which will allow the access-api ucanto server to accept
// invocations where aud=web3storageDid
DID: web3storageDid,
// @ts-ignore
PRIVATE_KEY: privateKeyFromEnv ?? process.env.PRIVATE_KEY,
UPLOAD_API_URL: mockUpstreamUrl.toString(),
},
})
const spaceCreation = await createSpace(
issuer,
Expand Down Expand Up @@ -89,7 +91,9 @@ describe('proxy store/list invocations to upload-api', function () {
Array.from({ length: 3 }).map(() => ed25519.Signer.generate())
)
const { service: serviceSigner, conn } = await context({
UPLOAD_API_URL: mockUpstreamUrl.toString(),
env: {
UPLOAD_API_URL: mockUpstreamUrl.toString(),
},
})
const service = process.env.DID
? serviceSigner.withDID(ucanto.DID.parse(process.env.DID).did())
Expand Down
6 changes: 4 additions & 2 deletions packages/access-api/test/ucan.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,10 @@ describe('ucan', function () {
test('should support ucan invoking to a did:web aud', async function () {
const serviceDidWeb = 'did:web:example.com'
const { mf, issuer, service } = await context({
...process.env,
DID: serviceDidWeb,
env: {
...process.env,
DID: serviceDidWeb,
},
})
const ucan = await UCAN.issue({
issuer,
Expand Down
8 changes: 5 additions & 3 deletions packages/access-api/test/upload-api-proxy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ function testCanProxyInvocation(can) {
})
const mockUpstreamUrl = serverLocalUrl(mockUpstreamHttp.address())
const { issuer, conn } = await context({
UPLOAD_API_URL: mockUpstreamUrl.toString(),
// @ts-expect-error This expects did:web
DID: upstreamPrincipal.did(),
env: {
UPLOAD_API_URL: mockUpstreamUrl.toString(),
// @ts-expect-error This expects did:web
DID: upstreamPrincipal.did(),
},
})
/** @type {Ucanto.ConnectionView<any>} */
const connection = conn
Expand Down
4 changes: 2 additions & 2 deletions packages/access-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
VoucherRedeem,
Top,
AccessAuthorize,
AccessAuthorizeSuccess,
AccessDelegate,
AccessDelegateFailure,
AccessDelegateSuccess,
Expand Down Expand Up @@ -95,8 +96,7 @@ export interface SpaceTableMetadata {
*/
export interface Service {
access: {
// returns a URL string for tests or nothing in other envs
authorize: ServiceMethod<AccessAuthorize, string | undefined, Failure>
authorize: ServiceMethod<AccessAuthorize, AccessAuthorizeSuccess, Failure>
claim: ServiceMethod<AccessClaim, AccessClaimSuccess, AccessClaimFailure>
delegate: ServiceMethod<
AccessDelegate,
Expand Down
5 changes: 5 additions & 0 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import * as UploadCaps from './upload.js'
import { claim, redeem } from './voucher.js'
import * as AccessCaps from './access.js'

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface Unit {}
/**
* failure due to a resource not having enough storage capacity.
*/
Expand All @@ -22,6 +24,9 @@ export type Access = InferInvokedCapability<typeof AccessCaps.access>
export type AccessAuthorize = InferInvokedCapability<
typeof AccessCaps.authorize
>

// eslint-disable-next-line @typescript-eslint/no-empty-interface
export type AccessAuthorizeSuccess = Unit
export type AccessClaim = InferInvokedCapability<typeof AccessCaps.claim>
export interface AccessClaimSuccess {
delegations: Record<string, Ucanto.ByteView<Ucanto.Delegation>>
Expand Down

0 comments on commit b4b0173

Please sign in to comment.