108 lines
3.2 KiB
TypeScript
108 lines
3.2 KiB
TypeScript
import type { SQL } from "drizzle-orm";
|
|
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const state = vi.hoisted(() => ({
|
|
allowed: true,
|
|
queries: [] as { sql: string; params: unknown[] }[],
|
|
rows: [] as unknown[],
|
|
}));
|
|
vi.mock("@/lib/auth", () => ({ auth: vi.fn() }));
|
|
vi.mock("@/lib/permissions", () => ({
|
|
getApiAdminContext: async () => ({
|
|
session: { user: { id: 42, rank: 1 } },
|
|
permissions: {},
|
|
}),
|
|
canAccess: () => state.allowed,
|
|
}));
|
|
vi.mock("@/lib/admin/authorization-events", () => ({
|
|
logAuthorizationEvent: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/rate-limit", () => ({ rateLimit: async () => ({ ok: true }) }));
|
|
vi.mock("@/lib/report-error", () => ({ reportError: vi.fn() }));
|
|
vi.mock("@/lib/foundation/security", () => ({
|
|
extractClientIpAsync: async () => "127.0.0.1",
|
|
}));
|
|
vi.mock("@/lib/db", () => {
|
|
const execute = async (sql: SQL) => {
|
|
const query = new MySqlDialect().sqlToQuery(sql);
|
|
state.queries.push(query);
|
|
return [state.rows];
|
|
};
|
|
return {
|
|
db: {
|
|
execute,
|
|
transaction: async (fn: (tx: { execute: typeof execute }) => unknown) =>
|
|
fn({ execute }),
|
|
},
|
|
};
|
|
});
|
|
|
|
import {
|
|
deleteTableView,
|
|
listTableViews,
|
|
saveTableView,
|
|
} from "./admin-table-views";
|
|
|
|
describe("saved table views access and ownership", () => {
|
|
beforeEach(() => {
|
|
state.allowed = true;
|
|
state.rows = [];
|
|
state.queries = [];
|
|
});
|
|
it("denies ordinary signed-in users without staff permissions before SQL", async () => {
|
|
state.allowed = false;
|
|
expect((await listTableViews({ path: "/admin/users" })).ok).toBe(false);
|
|
expect(
|
|
(
|
|
await saveTableView({
|
|
path: "/admin/users",
|
|
name: "Mine",
|
|
state: { params: {}, hidden: [] },
|
|
})
|
|
).ok,
|
|
).toBe(false);
|
|
expect(
|
|
(await deleteTableView({ path: "/admin/users", name: "Mine" })).ok,
|
|
).toBe(false);
|
|
expect(state.queries).toHaveLength(0);
|
|
});
|
|
it("scopes reads and deletes to the session owner, table, and name", async () => {
|
|
await listTableViews({ path: "/admin/users" });
|
|
expect(state.queries[0].params).toEqual([42, "/admin/users"]);
|
|
expect(state.queries[0].sql).toContain("WHERE user_id = ? AND path = ?");
|
|
await deleteTableView({ path: "/admin/users", name: "Mine" });
|
|
expect(state.queries[1].params).toEqual([42, "/admin/users", "Mine"]);
|
|
expect(state.queries[1].sql).toContain(
|
|
"WHERE user_id = ? AND path = ? AND name = ?",
|
|
);
|
|
});
|
|
it("serializes changes on the owner row and stores only validated state", async () => {
|
|
const view = {
|
|
path: "/admin/users",
|
|
name: "Mine",
|
|
state: { params: { search: "Alice" }, hidden: ["mail"] },
|
|
};
|
|
expect((await saveTableView(view)).ok).toBe(true);
|
|
expect(state.queries[0]).toMatchObject({
|
|
sql: "SELECT id FROM users WHERE id = ? FOR UPDATE",
|
|
params: [42],
|
|
});
|
|
expect(state.queries[2].params).toEqual([
|
|
42,
|
|
"/admin/users",
|
|
"Mine",
|
|
JSON.stringify(view.state),
|
|
]);
|
|
});
|
|
it("rejects invalid stored states without returning them as usable views", async () => {
|
|
state.rows = [
|
|
{ name: "Broken", state: '{"params":{"token":"secret"},"hidden":[]}' },
|
|
];
|
|
expect(await listTableViews({ path: "/admin/users" })).toMatchObject({
|
|
ok: true,
|
|
data: { views: [] },
|
|
});
|
|
});
|
|
});
|