Skip to content
This repository was archived by the owner on Aug 26, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d5f67ef
[Phase1] test/db/ RLS検証(最小8項目 + 権限マトリクス全項目)
reitojike Aug 6, 2026
846fe65
fix: supabase status -o envの出力からダブルクォートを除去してGITHUB_ENVに渡す
reitojike Aug 6, 2026
3c3b647
debug: 招待テストの自己登録INSERT結果を明示的に検証する
reitojike Aug 6, 2026
9a41e75
debug: EXISTS相当のクエリが同セッションから見えるか診断する
reitojike Aug 6, 2026
c6717ec
fix: 招待INSERTポリシーの自己参照EXISTSがRLS再帰ですり抜ける問題を修正
reitojike Aug 6, 2026
7095964
debug: is_event_participant()をRPCで直接呼び出して権限/ロジックを切り分ける
reitojike Aug 6, 2026
65469c6
debug: 招待チェックの各条件を個別に返す診断関数を追加(調査用、後で削除)
reitojike Aug 6, 2026
fdfdee3
debug: debug_invite_check()で各条件を実値ベースで検証する
reitojike Aug 6, 2026
3dd91ee
fix: 招待検証をWITH CHECKからBEFORE INSERTトリガーに変更
reitojike Aug 6, 2026
acb99bb
debug: 一時的にINSERTポリシーをwith check(true)にして切り分ける
reitojike Aug 6, 2026
128be3c
fix: 招待テストの真因を修正(RLSではなくRETURNINGの読み戻し不可が原因だった)
reitojike Aug 6, 2026
e255bb0
fix: レビュー指摘2件に対応
reitojike Aug 6, 2026
a1326cb
fix: レビュー指摘対応(participation_state上書きテスト追加、依存関係の分類修正)
reitojike Aug 6, 2026
cdfaea4
fix: 「他人の◯◯は更新・削除できない」テストがUSING句の破損を検出できない問題を修正
reitojike Aug 6, 2026
057b6d2
fix: セットアップinsert/updateの成否を確認せず「静かに失敗」を見逃す穴を横展開修正
reitojike Aug 6, 2026
78386b2
fix: 招待テストの自己登録insertでも一貫してerrorチェックを追加(レビュー指摘)
reitojike Aug 6, 2026
0107d38
fix: 参加登録済みの他ユーザーによる編集拒否テストを追加 + セットアップerrorチェックの横展開完了
reitojike Aug 6, 2026
638c2cb
fix: 削除も検証するよう修正 + 残りのセットアップerrorチェック + なりすまし作成テスト追加
reitojike Aug 6, 2026
e6999bc
fix: 自分のステータス変更(正常系)のテストを追加(権限マトリクス網羅漏れ)
reitojike Aug 6, 2026
ecffd24
fix: budgets.test.tsのas constキャストを除去(CLAUDE.md絶対ルール準拠)
reitojike Aug 6, 2026
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
15 changes: 15 additions & 0 deletions .github/workflows/supabase.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ jobs:
- run: supabase start
if: steps.check.outputs.initialized == 'true'

# test/db/ が認証済みクライアントでRLSを検証するために接続情報を渡す。
# service_roleキーはエクスポートしない(test/db/では使わない。docs/permissions.md)。
- name: Export local Supabase connection info
if: steps.check.outputs.initialized == 'true'
# supabase status -o env は値をダブルクォートで囲んで出力するが、
# GITHUB_ENV はクォートを解釈しないため、そのまま渡すと値にクォートが
# 含まれてしまう(例: API_URL が `"http://127.0.0.1:54321"` になる)。
# --override-nameは指定したキーの出力名を変えるだけで、SERVICE_ROLE_KEY等の
# 他のキーはそのまま出力に残る。API_URL/ANON_KEY以外がGITHUB_ENVに
# 紛れ込まないよう、grepで対象の2行だけに絞ってから渡す。
run: |
supabase status -o env --override-name api.url=API_URL --override-name auth.anon_key=ANON_KEY \
| grep -E '^(API_URL|ANON_KEY)=' \
| sed 's/^\([A-Z_]*\)="\(.*\)"$/\1=\2/' >> "$GITHUB_ENV"
Comment thread
Copilot marked this conversation as resolved.
Resolved
Comment on lines +56 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

コメントでは「service_roleキーはエクスポートしない」とありますが、--override-name は指定した2つのキー(api.url→API_URL, auth.anon_key→ANON_KEY)の出力名を変えるだけで、supabase status -o env が出力する他のキー(SERVICE_ROLE_KEY / JWT_SECRET / DB_URL など)はデフォルト名のままそのまま出力に残ります。後段の sed は ^[A-Z_]*="..."$ にマッチする行を全部 GITHUB_ENV に追記するので、結果的に SERVICE_ROLE_KEY も含めて全キーがジョブの環境変数としてエクスポートされてしまう可能性が高いです。

ローカルSupabaseの鍵は固定値で実害は小さいですが、コメントの主張(「エクスポートしない」)と実際の挙動が食い違っており、docs/permissions.md/CLAUDE.mdが明示している「service_roleキーはテストで一切使わない」という前提を、CI環境変数上ではこの手順は守れていません。test/db/ の将来のテストが process.env.SERVICE_ROLE_KEY を安易に使ってしまう入口にもなり得るので、sed の抽出パターンを API_URL/ANON_KEY の2行だけに絞る(例: grep -E '^(API_URL|ANON_KEY)=')ことを推奨します。


- run: yarn test:db

types-check:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"typescript": "^5"
},
"devDependencies": {
"@supabase/supabase-js": "^2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
Expand Down
Empty file removed test/db/.gitkeep
Empty file.
130 changes: 130 additions & 0 deletions test/db/budgets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { expect, test } from "vitest";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ナイトピック: events.test.ts(82-91行目)とevent-participants.test.ts(17-29行目)には「他人になりすまして…できない」という、user_idに自分以外のIDを指定したINSERTが拒否されることを確認するテストがありますが、budgets/expenses/ticket_entriesの3ファイルには同種のテストがありません。

docs/permissions.mdの最小検証セットには明示されていませんが、いずれのテーブルもwith check (user_id = auth.uid())という同じ形のINSERTポリシーを持っており、将来この条件が壊れた場合に検知できるのはこの種のなりすましテストだけです。他の2テーブルとの対称性のためにも追加を検討してください(必須ではありません)。

import { createTestUser } from "./helpers";

test("本人はbudgetsを作成・閲覧できる", async () => {
const user = await createTestUser();
const { data, error } = await user.client
.from("budgets")
.insert({
user_id: user.userId,
period_type: "monthly",
period_start: "2026-08-01",
amount: 10000,
})
.select()
.single();
expect(error).toBeNull();
expect(data?.user_id).toBe(user.userId);
});

test("他人になりすましてbudgetsを作成できない", async () => {
const [actor, victim] = await Promise.all([createTestUser(), createTestUser()]);
const { error } = await actor.client.from("budgets").insert({
user_id: victim.userId,
period_type: "monthly",
period_start: "2026-08-01",
amount: 10000,
});
expect(error).not.toBeNull();
});

test("他人のbudgetsは見えない", async () => {
const [self, stranger] = await Promise.all([createTestUser(), createTestUser()]);
const { data: created, error: createError } = await self.client
.from("budgets")
.insert({
user_id: self.userId,
period_type: "monthly",
period_start: "2026-08-01",
amount: 10000,
})
.select()
.single();
expect(createError).toBeNull();

const { data, error } = await stranger.client
.from("budgets")
.select()
.eq("id", created?.id ?? "");
expect(error).toBeNull();
expect(data).toHaveLength(0);
});

test("他人のbudgetsは更新・削除できない", async () => {
const [self, stranger] = await Promise.all([createTestUser(), createTestUser()]);
const { data: created, error: createError } = await self.client
.from("budgets")
.insert({
user_id: self.userId,
period_type: "monthly",
period_start: "2026-08-01",
amount: 10000,
})
.select()
.single();
expect(createError).toBeNull();
const id = created?.id ?? "";

const { data: updated } = await stranger.client
.from("budgets")
.update({ amount: 1 })
.eq("id", id)
.select();
expect(updated).toHaveLength(0);

// strangerは他人のbudgetsをSELECTできないため、RETURNINGが空なだけでは
// USING句が効いているか分からない。本人視点で実際に変化していないことを確認する。
const { data: afterUpdate } = await self.client
.from("budgets")
.select("amount")
.eq("id", id)
.single();
expect(afterUpdate?.amount).toBe(10000);

const { data: deleted } = await stranger.client.from("budgets").delete().eq("id", id).select();
expect(deleted).toHaveLength(0);
Comment on lines +53 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

このテストは実質的にUPDATE/DELETEポリシーを検証できていない可能性があります。

strangerはbudgets_select_ownポリシーによりselfの行をそもそもSELECTできません。Postgres RLSではUPDATE ... RETURNING/DELETE ... RETURNINGもSELECTポリシーで絞られるため、UPDATE/DELETEのUSING句が仮にtrueのように壊れて他人の行を実際に書き換え/削除できてしまっていても、stranger視点のRETURNINGは同じく空配列になり、toHaveLength(0)は変わらず通ります。

これはこのPR自身が招待INSERTで踏んだ落とし穴(コミット「招待テストの真因を修正: RLSではなくRETURNINGの読み戻し不可が原因だった」)と同じ構造で、docs/testing.mdの「対象を壊したら赤くなることを確認する」を満たせていません。

self.client側で更新後もamountが元のまま・削除後も行が存在することを追加で確認する必要があると思います(events.test.tsの無関係のユーザーは他人のイベントを編集できないテストがowner.clientで不変を確認しているのと同じパターン)。


const { data: stillExists } = await self.client.from("budgets").select().eq("id", id);
expect(stillExists).toHaveLength(1);
});

test("本人は自分のbudgetsを削除できる", async () => {
const user = await createTestUser();
const { data: created, error: createError } = await user.client
.from("budgets")
.insert({
user_id: user.userId,
period_type: "monthly",
period_start: "2026-08-01",
amount: 10000,
})
.select()
.single();
expect(createError).toBeNull();
const id = created?.id ?? "";

const { error } = await user.client.from("budgets").delete().eq("id", id);
expect(error).toBeNull();

const { data: gone } = await user.client.from("budgets").select().eq("id", id);
expect(gone).toHaveLength(0);
});

test("同一期間の全ジャンル合算枠(genre NULL)は重複作成できない", async () => {
const user = await createTestUser();
const first = await user.client.from("budgets").insert({
user_id: user.userId,
period_type: "monthly",
period_start: "2026-09-01",
amount: 10000,
});
expect(first.error).toBeNull();

const second = await user.client.from("budgets").insert({
user_id: user.userId,
period_type: "monthly",
period_start: "2026-09-01",
amount: 10000,
});
expect(second.error).not.toBeNull();
});
Loading
Loading