Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/web/server/routes/rpc/[...].ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ export default defineEventHandler(async (event) => {
get db() {
return useDatabase()
},
// このゲッターは参照のたびに新しい { sdk } を作る。SDK 本体は useStripe が
// モジュールスコープでキャッシュするので、包みを作り直しても SDK は 1 つのままである。
// 実体をインスタンス単位でキャッシュを持つものに替える場合は、参照のたびに新しい
// インスタンスが生成されるので、包みをモジュールスコープへ持ち上げてから替えること。
get stripe() {
return useStripe()
return { sdk: useStripe() }
},
// 変更系ガードのフラグ。既定 false で無認証の書き込みを塞ぐ(#15 で認可に置き換え)。
mutationsEnabled: useRuntimeConfig().enableUnsafeMutations,
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const checkoutRouter = {
if (input.limit !== undefined) params.limit = input.limit
if (input.startingAfter !== undefined) params.starting_after = input.startingAfter

const page = await context.stripe.checkout.sessions.list(params)
const page = await context.stripe.sdk.checkout.sessions.list(params)
return toListResponse(page, toCheckoutSessionView)
}),
},
Expand Down
19 changes: 17 additions & 2 deletions packages/api/src/invoices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,15 +220,30 @@ describe('invoices.issue', () => {
})

it('mutationsEnabled が false なら作成を拒否する(認可導入までの暫定ガード)', async () => {
const context = testContext({ invoices: { create: () => Promise.resolve({ id: 'in_1' }) } }, false)
let called = false
const context = testContext(
{
invoices: {
create: () => {
called = true
return Promise.resolve({ id: 'in_1' })
},
},
},
false,
)

await expect(
call(
appRouter.invoices.issue,
{ customer: 'cus_1', price: 'price_1', daysUntilDue: 14, idempotencyKey: 'idem_1' },
{ context },
),
).rejects.toThrow()
).rejects.toThrow(expect.objectContaining({ code: 'FORBIDDEN' }))
// ガードより後ろで Stripe への書き込みが起きていないことを見る(ガードを書き込みの後ろへ動かす変更を検出する)。
// 記録するのはスタブが供給する invoices.create だけでよい。後続の invoiceItems.create と
// invoices.finalizeInvoice はスタブに無く、到達すれば TypeError になって上の検査が落ちる。
expect(called).toBe(false)
})

// 実装は hosted_invoice_url の null と undefined の両方をエラーにする。両ケースを個別に検証する。
Expand Down
8 changes: 4 additions & 4 deletions packages/api/src/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const invoicesRouter = {
if (input.limit !== undefined) params.limit = input.limit
if (input.startingAfter !== undefined) params.starting_after = input.startingAfter

const page = await context.stripe.invoices.list(params)
const page = await context.stripe.sdk.invoices.list(params)
return toListResponse(page, toInvoiceView)
}),

Expand Down Expand Up @@ -55,9 +55,9 @@ export const invoicesRouter = {
days_until_due: input.daysUntilDue,
}

const invoice = await context.stripe.invoices.create(params, idem('create'))
const invoice = await context.stripe.sdk.invoices.create(params, idem('create'))

await context.stripe.invoiceItems.create(
await context.stripe.sdk.invoiceItems.create(
{
customer: input.customer,
pricing: { price: input.price },
Expand All @@ -66,7 +66,7 @@ export const invoicesRouter = {
idem('item'),
)

const finalized = await context.stripe.invoices.finalizeInvoice(
const finalized = await context.stripe.sdk.invoices.finalizeInvoice(
invoice.id,
undefined,
idem('finalize'),
Expand Down
6 changes: 5 additions & 1 deletion packages/api/src/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { contract } from '@checkin/api-contract'
*/
export interface Context {
db: Database
stripe: Stripe
/**
* Stripe SDK への到達経路。プロシージャは Context の stripe.sdk から SDK を取得する。
* sdk プロパティで Stripe を返すオブジェクトなら、プロシージャを変えずに実体を差し替えられる。
*/
stripe: { sdk: Stripe }
/** 変更系(作成・更新)を許可するか。方針は project.md、機構は assertMutationsEnabled を参照。 */
mutationsEnabled: boolean
}
Expand Down
17 changes: 15 additions & 2 deletions packages/api/src/prices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,23 @@ describe('prices.update', () => {
})

it('mutationsEnabled が false なら更新を拒否する(認可導入までの暫定ガード)', async () => {
const context = testContext({ prices: { update: () => Promise.resolve(price({})) } }, false)
let called = false
const context = testContext(
{
prices: {
update: () => {
called = true
return Promise.resolve(price({}))
},
},
},
false,
)

await expect(
call(appRouter.prices.update, { id: 'price_x', active: false }, { context }),
).rejects.toThrow()
).rejects.toThrow(expect.objectContaining({ code: 'FORBIDDEN' }))
// ガードより後ろで Stripe への書き込みが起きていないことを見る(ガードを書き込みの後ろへ動かす変更を検出する)。
expect(called).toBe(false)
})
})
6 changes: 3 additions & 3 deletions packages/api/src/prices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function toPriceView(price: Stripe.Price): PriceView {
/** 価格プロシージャ — Stripe の Price を Checkin API として公開する。 */
export const pricesRouter = {
retrieve: pub.prices.retrieve.handler(async ({ input, context }) =>
toPriceView(await context.stripe.prices.retrieve(input.id)),
toPriceView(await context.stripe.sdk.prices.retrieve(input.id)),
),

list: pub.prices.list.handler(async ({ input, context }) => {
Expand All @@ -31,12 +31,12 @@ export const pricesRouter = {
if (input.limit !== undefined) params.limit = input.limit
if (input.startingAfter !== undefined) params.starting_after = input.startingAfter

const page = await context.stripe.prices.list(params)
const page = await context.stripe.sdk.prices.list(params)
return toListResponse(page, toPriceView)
}),

update: pub.prices.update.handler(async ({ input, context }) => {
assertMutationsEnabled(context)
return toPriceView(await context.stripe.prices.update(input.id, { active: input.active }))
return toPriceView(await context.stripe.sdk.prices.update(input.id, { active: input.active }))
}),
}
17 changes: 15 additions & 2 deletions packages/api/src/products.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,23 @@ describe('products.update', () => {
})

it('mutationsEnabled が false なら更新を拒否する(認可導入までの暫定ガード)', async () => {
const context = testContext({ products: { update: () => Promise.resolve(product({})) } }, false)
let called = false
const context = testContext(
{
products: {
update: () => {
called = true
return Promise.resolve(product({}))
},
},
},
false,
)

await expect(
call(appRouter.products.update, { id: 'prod_x', active: false }, { context }),
).rejects.toThrow()
).rejects.toThrow(expect.objectContaining({ code: 'FORBIDDEN' }))
// ガードより後ろで Stripe への書き込みが起きていないことを見る(ガードを書き込みの後ろへ動かす変更を検出する)。
expect(called).toBe(false)
})
})
6 changes: 3 additions & 3 deletions packages/api/src/products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function toProductView(product: Stripe.Product): ProductView {
/** 商品プロシージャ — Stripe の Product を Checkin API として公開する。 */
export const productsRouter = {
retrieve: pub.products.retrieve.handler(async ({ input, context }) =>
toProductView(await context.stripe.products.retrieve(input.id)),
toProductView(await context.stripe.sdk.products.retrieve(input.id)),
),

list: pub.products.list.handler(async ({ input, context }) => {
Expand All @@ -27,7 +27,7 @@ export const productsRouter = {
if (input.limit !== undefined) params.limit = input.limit
if (input.startingAfter !== undefined) params.starting_after = input.startingAfter

const page = await context.stripe.products.list(params)
const page = await context.stripe.sdk.products.list(params)
return toListResponse(page, toProductView)
}),

Expand All @@ -39,6 +39,6 @@ export const productsRouter = {
if (input.name !== undefined) params.name = input.name
if (input.description !== undefined) params.description = input.description

return toProductView(await context.stripe.products.update(input.id, params))
return toProductView(await context.stripe.sdk.products.update(input.id, params))
}),
}
10 changes: 6 additions & 4 deletions packages/api/src/test-utils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { Context } from './orpc'

/**
* テスト用の最小 Context。handler が実際に使う stripe リソースだけをスタブして渡す。
* db は触らせない。mutationsEnabled は既定で true(変更系ガードを通す)。ガード自体を
* 検証するテストでは false を渡す。
* テスト用の最小 Context。第1引数には、handler が実際に使う Stripe SDK のリソースだけを
* スタブしたオブジェクト(例: { prices: { list } })を、sdk で包まずにそのまま渡す。
* Context の stripe は { sdk: Stripe } 型なので、包むのはこの関数の側で行い、handler からは
* Context の stripe.sdk 経由で見えるようにする。db は触らせない。mutationsEnabled は既定で
* true(変更系ガードを通す)。ガード自体を検証するテストでは false を渡す。
*/
export function testContext(stripe: unknown, mutationsEnabled = true): Context {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- 実際に使う依存だけを供給する意図的なテストスタブ
return { db: {}, stripe, mutationsEnabled } as unknown as Context
return { db: {}, stripe: { sdk: stripe }, mutationsEnabled } as unknown as Context
}

/**
Expand Down