Open5

Supabase Edge Functionsのテストを書く

iharuyaiharuya

FunctionsでのSupabase clientは、ブラウザ実行(SPA)のときと同じように、Anon Keyを利用してRow Level Securityするか、Service Roke Keyを使ってルート権限で実行させる(別途認証・認可ロジックを自分で書く)パターンに分かれる。

両方のパターンに対応したヘルパーはこんな感じ。(アンダースコアから始まるディレクトリはルーティングから無視されるのでその中に共通ファイルを入れる。)

supabase/functions/_shared/client.ts

import { SupabaseClientOptions, createClient } from 'https://esm.sh/@supabase/supabase-js@2'
// import { createClient, SupabaseClient } from 'jsr:@supabase/supabase-js'
import { Database } from "../types/supabase.ts"

type Params = {
  auth?: string
  root?: boolean
}
const defaultParams: Params = {
  root: false
}
export const getSupabaseClient = <
  SchemaName extends string & keyof Database = 'public' extends keyof Database
  ? 'public'
  : string & keyof Database,
>({ auth, root }: Params = defaultParams) => {
  const supabaseUrl = Deno.env.get('SUPABASE_URL')!
  const supabaseKey = root ? Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! : Deno.env.get('SUPABASE_ANON_KEY')!
  const headers: Record<string, string> = {}
  if (auth) {
    headers.Authorization = auth
  }
  const options: SupabaseClientOptions<SchemaName> = {
    global: {
      headers
    },
    auth: {
      autoRefreshToken: false,
      persistSession: false,
      detectSessionInUrl: false,
    }
  }
  return createClient<Database, SchemaName>(supabaseUrl, supabaseKey, options)
}

ここで、Databaseという型は
supabase gen types typescript --localの結果である。フロントエンドとFunctionsが同じディレクトリにあるプロジェクトではpackage.jsonに以下を追加すると、両方で同じ型を使える。

"scripts": {
    ...
    "types": "supabase gen types typescript --local | tee src/types/supabase.ts supabase/functions/types/supabase.ts"
},

autoRefreshTokenをfalseにしておかないと、テストケースで以下のようなエラーが出てハマった。

error: Leaks detected:
  - An interval was started in this test, but never completed. This is often caused by not calling `clearInterval`. The operation was started here:
    at Object.queueUserTimer (ext:core/01_core.js:738:9)
    at setInterval (ext:deno_web/02_timers.js:83:15)
    at p._startAutoRefresh (https://esm.sh/v135/@supabase/auth-js@2.64.2/denonext/auth-js.mjs:2:41246)
    at async p.startAutoRefresh (https://esm.sh/v135/@supabase/auth-js@2.64.2/denonext/auth-js.mjs:2:41742)

また、このSupabase Client作成に関するテストファイルも一応作ってみた。

client.test.ts
import { assertEquals, assertInstanceOf } from "jsr:@std/assert"
import { getSupabaseClient } from "../_shared/client.ts";
import { AuthError } from 'https://esm.sh/@supabase/supabase-js@2'


Deno.test('Admin can create user', async (t) => {
  const client = getSupabaseClient({ root: true })
  let userId: string | undefined

  await t.step("Create user", async () => {
    const { data, error } = await client.auth.admin.createUser({
      email: "hoge@gmail.com",
      password: "password",
    })
    assertEquals(error, null)
    assertEquals(data.user?.email, "hoge@gmail.com")
    userId = data.user?.id
  })

  await t.step("Delete user", async () => {
    const { error } = await client.auth.admin.deleteUser(userId!);
    assertEquals(error, null);
  });
})

Deno.test("Anon can call edge function", async () => {
  const client = getSupabaseClient()
  const { data, error } = await client.functions.invoke("ping")
  assertEquals(data, "pong")
  assertEquals(error, null)
})

Deno.test("Anon do not have access to admin functions", async () => {
  const client = getSupabaseClient()
  const { data, error } = await client.auth.admin.createUser({
    email: "hoge@gmail.com",
    password: "password",
  })
  assertEquals(data.user, null)
  assertInstanceOf(error, AuthError)
  assertEquals(error?.code, "not_admin")
})

コレをfunctions/test/client.test.tsとなるように置きまして、

Functionsのping用のエントリポイントを追加する:functions/ping/index.ts

import "https://esm.sh/@supabase/functions-js/src/edge-runtime.d.ts"

Deno.serve((req) => {
  const headersObject = Object.fromEntries(req.headers)
  const headersString = JSON.stringify(headersObject, null, 2)
  console.log(`Request headers: ${headersString}`) 
  return new Response("pong")
})
iharuyaiharuya

テストファイルは

supabase/functions/testshoge.test.tsという形式でおく

iharuyaiharuya

テストの実行には、別途環境変数ファイルを用意して指定する必要がある。

Functionsのローカル実行では自動的にSUPABASE_から始まるもろもろの環境変数がセットされていて、追加でカスタム環境変数が必要なら

supabase/functions/.envとかを作って

supabase functions serve --env-file ./supabase/functions/.env

こうやって指定すればいい。

ただ、テスト実行時は普通のdeno testをホストデバイスで実行する為、そのような自動環境変数はセットされていない。

だからsupabase/functions/.env.testを作って

SUPABASE_URL="http://localhost:54321"
SUPABASE_ANON_KEY=""
SUPABASE_SERVICE_ROLE_KEY=""
MY_CUSTOM_KEY=""

として

deno test --allow-all --env=./supabase/functions/.env.test supabase/functions/tests/*.test.ts

すればいい。.env.testをgitignoreすることを忘れずに。

iharuyaiharuya

学校の授業でディレクトリ(パス)トラバーサル攻撃の概念を知ったから、functionsフォルダ以下のファイルに対する直接的なアクセスを全部ブロックできるかテストを書いてみた。

....あれ、やばくね?