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
116 changes: 116 additions & 0 deletions server/api/src/__tests__/routes/pdfSources.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* PDF ソース derive-page の権限テスト。
* Permission tests for POST /api/sources/pdf/:sourceId/highlights/:highlightId/derive-page.
*/
import { describe, it, expect, 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) => {
const userId = c.req.header("x-test-user-id");
if (!userId) return c.json({ message: "Unauthorized" }, 401);
c.set("userId", userId);
c.set("userEmail", "tester@example.com");
await next();
},
}));

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

vi.mock("../../services/defaultNoteService.js", () => ({
ensureDefaultNote: vi.fn(async (_db: unknown, userId: string) => ({
id: "default-note-mock",
ownerId: userId,
title: "Mock note",
visibility: "private" as const,
editPermission: "owner_only" as const,
isOfficial: false,
isDefault: true,
viewCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
isDeleted: false,
})),
}));

import { Hono } from "hono";
import { errorHandler } from "../../middleware/errorHandler.js";
import pdfSourcesRoutes from "../../routes/pdfSources.js";
import { createMockDb } from "../createMockDb.js";

const TEST_USER_ID = "user-pdf-derive-1";
const SOURCE_ID = "pdf-source-1";
const HIGHLIGHT_ID = "highlight-1";
const FOREIGN_NOTE_ID = "foreign-note-id";

const PDF_SOURCE_ROW = {
id: SOURCE_ID,
kind: "pdf_local" as const,
ownerId: TEST_USER_ID,
displayName: "Test PDF",
};

const HIGHLIGHT_ROW = {
id: HIGHLIGHT_ID,
sourceId: SOURCE_ID,
ownerId: TEST_USER_ID,
text: "Highlighted text",
derivedPageId: null,
};

function authHeaders(): Record<string, string> {
return {
"x-test-user-id": TEST_USER_ID,
"Content-Type": "application/json",
};
}

function createPdfSourcesApp(dbResults: unknown[]) {
const { db } = 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/sources", pdfSourcesRoutes);
return app;
}

describe("POST /api/sources/pdf/:sourceId/highlights/:highlightId/derive-page", () => {
it("returns 403 when noteId points to a note the caller cannot edit", async () => {
const foreignNote = {
id: FOREIGN_NOTE_ID,
ownerId: "other-user",
title: "Someone else's note",
visibility: "private" as const,
editPermission: "owner_only" as const,
isOfficial: false,
isDefault: false,
viewCount: 0,
createdAt: new Date(),
updatedAt: new Date(),
isDeleted: false,
memberRole: null,
domainRole: null,
};

const app = createPdfSourcesApp([[PDF_SOURCE_ROW], [HIGHLIGHT_ROW], [foreignNote]]);

const res = await app.request(
`/api/sources/pdf/${SOURCE_ID}/highlights/${HIGHLIGHT_ID}/derive-page`,
{
method: "POST",
headers: authHeaders(),
body: JSON.stringify({ noteId: FOREIGN_NOTE_ID, title: "Injected page" }),
},
);

expect(res.status).toBe(403);
});
});
20 changes: 16 additions & 4 deletions server/api/src/routes/pdfSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { PdfSourceMetadata } from "../schema/sources.js";
import { pageSources } from "../schema/pageSources.js";
import { pages } from "../schema/pages.js";
import { ensureDefaultNote } from "../services/defaultNoteService.js";
import { canEdit, getNoteRole } from "./notes/helpers.js";
import type { AppEnv } from "../types/index.js";

const app = new Hono<AppEnv>();
Expand Down Expand Up @@ -468,10 +469,21 @@ app.post(
? body.contentPreview.slice(0, 240)
: highlightRow.text.slice(0, 240);

// ノート所属を解決(default に寄せる)。ノート権限の細かなチェックは
// /api/pages POST と同等のロジックを将来 import で共有してもよい。
// Resolve note membership; default to the user's default note.
const resolvedNoteId: string = requestedNoteId ?? (await ensureDefaultNote(db, userId)).id;
// ノート所属を解決(default に寄せる)。明示的な noteId は POST /api/pages と
// 同様に編集権限を検証する(他ユーザーのノートへのページ注入を防ぐ)。
// Resolve note membership; when noteId is explicit, require edit access like POST /api/pages.
let resolvedNoteId: string;
if (requestedNoteId) {
const userEmail = c.get("userEmail");
const { role, note } = await getNoteRole(requestedNoteId, userId, userEmail, db);
if (!note) throw new HTTPException(404, { message: "Note not found" });
if (!role || !canEdit(role, note)) {
throw new HTTPException(403, { message: "Forbidden" });
}
resolvedNoteId = requestedNoteId;
} else {
resolvedNoteId = (await ensureDefaultNote(db, userId)).id;
}

const sectionAnchor = `pdf:v1:${highlightRow.id}`;

Expand Down
4 changes: 3 additions & 1 deletion server/mcp/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,9 @@ export function registerAllTools(server: McpServer, client: ZediClient): void {
{
title: "Remove page from note",
description:
"Removes a page from a note. The page itself is not deleted, only the linkage is removed.",
"Soft-deletes a page that belongs to the note (same as DELETE /api/notes/:noteId/pages/:pageId). " +
"There is no unlink-only mode after issue #823 — the page is marked deleted. " +
"Use zedi_delete_page when you intend to delete a page by id.",
inputSchema: { note_id: z.string().min(1), page_id: z.string().min(1) },
},
async (args) =>
Expand Down
Loading