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
12 changes: 12 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,15 @@
# The TEST_TOKEN at invite.test.ts:38 is a hard-coded mock string used only for
# invitation API unit tests — it is not a real API key or secret.
b1e5dab7fa191b9f317b066099ca3fbb6450c5a7:server/api/src/__tests__/routes/invite.test.ts:generic-api-key:38

# server/api/src/lib/githubAppAuth.test.ts:23 のコミット d10d11f に含まれる
# RSA 秘密鍵は、PR #814 (issue #805) のテスト用に生成された使い捨ての鍵で、
# 後続のコミット f6825d7 で `jose` モックに置き換えて削除済み。実際の署名には
# 一度も使われていないが、Git 履歴には残るため fingerprint を追加して
# 誤検知扱いにする。
# The RSA private key in commit d10d11f at githubAppAuth.test.ts:23 was a
# disposable test key generated solely for PR #814 (issue #805). The follow-up
# commit f6825d7 replaced the JWT path with a `jose` mock and removed the key
# from HEAD; it was never used to sign anything outside this test file. Git
# history retains the blob, so we suppress the historical match here.
d10d11f5fa92743bb5a8214ca5c088d7cb4b01dd:server/api/src/lib/githubAppAuth.test.ts:private-key:23
23 changes: 23 additions & 0 deletions server/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,26 @@ SENTRY_DSN_API=
# Sentry Internal Integration の Client Secret。`/api/webhooks/sentry` に
# 届く `Sentry-Hook-Signature` ヘッダの HMAC-SHA256 署名検証に使う。
SENTRY_WEBHOOK_SECRET=

# GitHub App credentials for Epic #616 Phase 2 (AI error analysis).
# - GITHUB_APP_ID: numeric App id from Settings → Developer settings → GitHub Apps.
# - GITHUB_APP_PRIVATE_KEY: PKCS#8 PEM-formatted private key. Multi-line PEM is
# accepted; in `.env` files you can encode newlines as the literal `\n`.
# - GITHUB_APP_INSTALLATION_ID: numeric installation id for the repository the
# App is installed on.
# - GITHUB_DISPATCH_REPOSITORY: `owner/repo` to receive `repository_dispatch`
# events (typically the repository the App is installed on). When unset, the
# Sentry webhook skips the dispatch silently — useful while the GitHub
# Actions workflow is not deployed yet.
#
# Epic #616 Phase 2 用の GitHub App 認証情報(AI エラー解析)。
# - GITHUB_APP_ID: GitHub App の数値 ID。
# - GITHUB_APP_PRIVATE_KEY: PKCS#8 PEM 形式の private key(`\n` リテラル可)。
# - GITHUB_APP_INSTALLATION_ID: インストール先リポジトリの installation ID。
# - GITHUB_DISPATCH_REPOSITORY: `repository_dispatch` 先(`owner/repo`)。
# 未設定時は Sentry webhook 側で dispatch をスキップする(Phase 2 の Actions
# が未デプロイでも API がデグレしないため)。
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
GITHUB_APP_INSTALLATION_ID=
GITHUB_DISPATCH_REPOSITORY=
320 changes: 320 additions & 0 deletions server/api/src/__tests__/routes/webhooks/githubAiCallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,320 @@
/**
* `PUT /api/webhooks/github/ai-result/:id` — AI 解析結果コールバックのテスト
* (Epic #616 Phase 2 / sub-issue #805)。
*
* Tests for the GitHub Actions AI analysis callback. Covers:
* - 401: missing / malformed bearer token
* - 403: bearer token rejected by GitHub-side validation
* - 400: malformed body / invalid severity / malformed suspected files
* - 404: unknown id / non-UUID id
* - 200: successful update with AI analysis fields
*/
import { Hono } from "hono";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import type { Context, Next } from "hono";
import type { AppEnv } from "../../../types/index.js";

vi.mock("../../../middleware/auth.js", () => ({
authRequired: async (_c: Context<AppEnv>, next: Next) => {
await next();
},
authOptional: async (_c: Context<AppEnv>, next: Next) => {
await next();
},
}));

import githubAiCallbackRoutes from "../../../routes/webhooks/githubAiCallback.js";
import { errorHandler } from "../../../middleware/errorHandler.js";
import { createMockDb } from "../notes/setup.js";

const VALID_UUID = "00000000-0000-0000-0000-000000000001";

/**
* テスト用アプリ。`dbResults` はハンドラ内のクエリ結果を順番に返す。
* Build a test app whose mock DB returns `dbResults` in order.
*/
function createApp(dbResults: unknown[]) {
const { db, chains } = createMockDb(dbResults);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", githubAiCallbackRoutes);
return { app, chains };
}

/**
* `verifyInstallationToken` を一定の戻り値で差し替える。
* Stub `verifyInstallationToken` to a fixed boolean for the duration of one
* test. We bypass the GitHub round-trip so callback tests don't hit the
* network.
*/
function stubVerifyInstallationToken(result: boolean | (() => Promise<boolean>)) {
return vi.doMock("../../../lib/githubAppAuth.js", async () => {
const actual = await vi.importActual<typeof import("../../../lib/githubAppAuth.js")>(
"../../../lib/githubAppAuth.js",
);
return {
...actual,
verifyInstallationToken: typeof result === "function" ? result : async () => result,
};
});
}

describe("PUT /api/webhooks/github/ai-result/:id", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "log").mockImplementation(() => undefined);
process.env.GITHUB_APP_ID = "123";
process.env.GITHUB_APP_INSTALLATION_ID = "456";
process.env.GITHUB_APP_PRIVATE_KEY = "stub";
});

afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
delete process.env.GITHUB_APP_ID;
delete process.env.GITHUB_APP_INSTALLATION_ID;
delete process.env.GITHUB_APP_PRIVATE_KEY;
});

it("returns 401 when Authorization header is missing", async () => {
const { app } = createApp([]);
const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ severity: "high" }),
});
expect(res.status).toBe(401);
});

it("returns 401 when Authorization header is not a Bearer token", async () => {
const { app } = createApp([]);
const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Token abc" },
body: JSON.stringify({ severity: "high" }),
});
expect(res.status).toBe(401);
});

it("returns 404 when :id is not a valid UUID (no DB query issued)", async () => {
const { app, chains } = createApp([]);
const res = await app.request("/api/webhooks/github/ai-result/not-a-uuid", {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_fake" },
body: JSON.stringify({ severity: "high" }),
});
expect(res.status).toBe(404);
expect(chains).toHaveLength(0);
});

it("returns 503 when verifyInstallationToken throws (transient GitHub outage)", async () => {
// GitHub 側の 5xx / ネットワーク障害は 403 ではなく 503 にマップされ、
// workflow 側でリトライ可能であることを示す。
// GitHub-side outages must surface as 503 (retryable) rather than 403,
// so a transient outage doesn't permanently drop a valid AI result.
vi.resetModules();
await vi.doMock("../../../lib/githubAppAuth.js", async () => {
const actual = await vi.importActual<typeof import("../../../lib/githubAppAuth.js")>(
"../../../lib/githubAppAuth.js",
);
return {
...actual,
verifyInstallationToken: async () => {
throw new actual.GitHubInstallationVerificationError("upstream 503");
},
};
});
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const { db } = createMockDb([]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_xx" },
body: JSON.stringify({ severity: "high" }),
});
expect(res.status).toBe(503);
});

it("returns 403 when verifyInstallationToken rejects the token", async () => {
// verifyInstallationToken は外部 (GitHub) を叩くので必ずモックする。
// Always stub verifyInstallationToken because it would otherwise hit
// GitHub. Here we simulate "GitHub said no, this token isn't ours".
vi.resetModules();
await stubVerifyInstallationToken(false);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const { db } = createMockDb([]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_bad" },
body: JSON.stringify({ severity: "high" }),
});
expect(res.status).toBe(403);
});

it("returns 400 on invalid JSON body", async () => {
vi.resetModules();
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const { db } = createMockDb([]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_ok" },
body: "{not-json",
});
expect(res.status).toBe(400);
});

it("returns 400 when body is JSON null", async () => {
vi.resetModules();
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const { db } = createMockDb([]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_ok" },
body: "null",
});
expect(res.status).toBe(400);
});

it("returns 200 and updates the row when payload is valid", async () => {
vi.resetModules();
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const updated = {
id: VALID_UUID,
sentryIssueId: "abc",
severity: "high",
aiSummary: "ヌルポインタ参照",
aiSuspectedFiles: [{ path: "src/a.ts", line: 12 }],
aiRootCause: "X が undefined",
aiSuggestedFix: "guard 追加",
};
// updateAiAnalysis issues a single update chain that resolves to [row].
const { db, chains } = createMockDb([[updated]]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_ok" },
body: JSON.stringify({
severity: "high",
ai_summary: "ヌルポインタ参照",
ai_suspected_files: [{ path: "src/a.ts", line: 12 }],
ai_root_cause: "X が undefined",
ai_suggested_fix: "guard 追加",
}),
});

expect(res.status).toBe(200);
const body = (await res.json()) as { data: { id: string; severity: string } };
expect(body.data.id).toBe(VALID_UUID);
expect(body.data.severity).toBe("high");
expect(chains.filter((c) => c.startMethod === "update")).toHaveLength(1);
});

it("returns 400 when severity is not a recognized value", async () => {
vi.resetModules();
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const { db } = createMockDb([]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_ok" },
body: JSON.stringify({ severity: "garbage" }),
});
expect(res.status).toBe(400);
});

it("returns 400 when ai_suspected_files is not an array of objects with .path", async () => {
vi.resetModules();
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
const { db } = createMockDb([]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_ok" },
body: JSON.stringify({ ai_suspected_files: [{ noPath: true }] }),
});
expect(res.status).toBe(400);
});

it("returns 404 when the row does not exist", async () => {
vi.resetModules();
await stubVerifyInstallationToken(true);
const { default: routes } = await import("../../../routes/webhooks/githubAiCallback.js");
// updateAiAnalysis returns null when the UPDATE returns no rows.
const { db } = createMockDb([[]]);
const app = new Hono<AppEnv>();
app.onError(errorHandler);
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/webhooks/github/ai-result", routes);

const res = await app.request(`/api/webhooks/github/ai-result/${VALID_UUID}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: "Bearer ghs_ok" },
body: JSON.stringify({ severity: "high" }),
});
expect(res.status).toBe(404);
});
});
Loading
Loading