Skip to content

Commit 95d76ea

Browse files
committed
test(content): regression tests for Content Engine access rules
Pin the access rules end to end with real seams (memory DB, context values, staff permission cache) and a shared access world fixture: - policy: prerequisites, audiences, deny-all for unreadable rules, root, inheritance order and code defaults - hierarchy: ancestor view/read rules, unknown scopes deny - multi-scope intersection, revoked roles and moved containers on the next request - public lists filtered before pagination, 404/403 detail, container visibility and flags, private caching, reply comment checks - admin access routes: errors, content.access.updated, permission matrix - search access filter, hit verification and skipAccess - protected downloads and attachment stripping - plugin route not-found messages and empty breadcrumbs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CnAiFc4gJkRmXr1TwfkSNX
1 parent d05ed57 commit 95d76ea

13 files changed

Lines changed: 1633 additions & 2 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { Context } from "hono";
2+
3+
import { PgDialect } from "drizzle-orm/pg-core";
4+
import { describe, expect, it } from "vitest";
5+
6+
import { searchIndexFilters } from "./search-filters";
7+
8+
const c = { get: () => undefined } as unknown as Context;
9+
10+
const render = (accessScopes?: null | string[]) => {
11+
const condition = searchIndexFilters(c, {
12+
accessScopes,
13+
includePrivate: true,
14+
});
15+
16+
return condition ? new PgDialect().sqlToQuery(condition) : undefined;
17+
};
18+
19+
describe("searchIndexFilters access scopes", () => {
20+
it.each([[["forum.forums:1", "blog.category:3"]], [[]]])(
21+
"keeps a row only when every scope it is filed in is in %j",
22+
allowed => {
23+
expect(render(allowed)).toEqual({
24+
params: [allowed],
25+
sql: '"core_search_index"."accessScopes" <@ $1::text[]',
26+
typings: undefined,
27+
});
28+
},
29+
);
30+
31+
it.each([undefined, null])("adds no access filter for %s", allowed => {
32+
expect(render(allowed)).toBeUndefined();
33+
});
34+
});

‎packages/vitnode/src/api/models/search.test.ts‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from "vitest";
77
import type { RegisteredContentType } from "@/content/registry";
88

99
import { core_search_index } from "@/database/search";
10+
import { createAccessWorld, TOPIC, USER } from "@/tests/content-access";
1011
import {
1112
testBigintEventContentType,
1213
testLocalizedPageContentType,
@@ -535,3 +536,97 @@ describe("item ids under every strategy", () => {
535536
expect(searchItemIdFromKey(types, "forum.topic", "abc")).toBe("abc");
536537
});
537538
});
539+
540+
describe("SearchModel access rules", () => {
541+
const hit = (itemId: number) => ({
542+
author: null,
543+
authorId: null,
544+
authorIds: [],
545+
authors: [],
546+
containerId: null,
547+
containerType: null,
548+
content: "",
549+
createdAt: new Date("2026-01-01"),
550+
id: itemId,
551+
image: null,
552+
imageId: null,
553+
isHidden: false,
554+
isPublic: true,
555+
itemId: String(itemId),
556+
itemType: "test.access-topic",
557+
languageCode: "",
558+
metadata: {},
559+
pluginId: "@vitnode/example",
560+
score: null,
561+
title: `Topic ${itemId}`,
562+
url: null,
563+
});
564+
565+
const searchAs = async (
566+
userId: null | number,
567+
options: Parameters<typeof createAccessWorld>[0] = {},
568+
) => {
569+
const world = await createAccessWorld(options);
570+
const provider = createProvider();
571+
const values = world.contextValues(userId);
572+
const core = {
573+
...(values.core as object),
574+
search: { adapter: provider },
575+
};
576+
const c = {
577+
get: (key: string) => (key === "core" ? core : values[key]),
578+
} as unknown as Context;
579+
580+
return { model: new SearchModel(c), provider };
581+
};
582+
583+
it("adds no access filter while nothing is restricted", async () => {
584+
const { model, provider } = await searchAs(null, { rules: {} });
585+
586+
await model.search({ term: "hello" });
587+
588+
expect(provider.search).toHaveBeenCalledWith(expect.anything(), {
589+
term: "hello",
590+
});
591+
});
592+
593+
it("limits a restricted viewer to the scopes they may read", async () => {
594+
const { model, provider } = await searchAs(null);
595+
596+
await model.search({ term: "hello" });
597+
598+
const [, params] = vi.mocked(provider.search).mock.calls[0];
599+
expect([...(params.accessScopes ?? [])].sort()).toEqual([
600+
"test.access-boards:1",
601+
"test.access-boards:5",
602+
]);
603+
});
604+
605+
it("lets a staff route skip the rules, and never trusts a caller's scopes", async () => {
606+
const { model, provider } = await searchAs(null);
607+
608+
await model.search({ accessScopes: ["anything:1"], skipAccess: true });
609+
610+
expect(provider.search).toHaveBeenCalledWith(expect.anything(), {});
611+
});
612+
613+
it("drops a hit the content tables no longer let the viewer read, and stops counting it", async () => {
614+
const { model, provider } = await searchAs(USER.MEMBER);
615+
vi.mocked(provider.search).mockResolvedValue({
616+
edges: [hit(TOPIC.OPEN), hit(TOPIC.VIP)],
617+
pageInfo: {
618+
count: 2,
619+
endCursor: null,
620+
hasNextPage: false,
621+
hasPreviousPage: false,
622+
startCursor: null,
623+
totalCount: 10,
624+
},
625+
});
626+
627+
const result = await model.search({ term: "topic" });
628+
629+
expect(result.edges.map(edge => edge.itemId)).toEqual([String(TOPIC.OPEN)]);
630+
expect(result.pageInfo).toMatchObject({ count: 1, totalCount: 9 });
631+
});
632+
});
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type {
4+
ContentAccessActor,
5+
ContentAccessAudience,
6+
ContentAccessDefaultsInput,
7+
ContentAccessScopeLayer,
8+
} from "./policy";
9+
10+
import {
11+
CONTENT_ACCESS_DENY_ALL,
12+
contentAccessRequirements,
13+
decideContentAccess,
14+
readContentAccessOverrides,
15+
resolveContentAccessRules,
16+
resolveContentAccessScopeInput,
17+
} from "./policy";
18+
19+
const GUEST: ContentAccessActor = { kind: "guest" };
20+
const member = (roleIds: number[], root = false): ContentAccessActor => ({
21+
kind: "member",
22+
roleIds,
23+
root,
24+
userId: 1,
25+
});
26+
27+
const MEMBERS: ContentAccessAudience = { mode: "members" };
28+
const ROLES_4_5: ContentAccessAudience = { mode: "roles", roleIds: [4, 5] };
29+
30+
const allows = (
31+
actor: ContentAccessActor,
32+
chain: ContentAccessScopeLayer[],
33+
action: Parameters<typeof contentAccessRequirements>[0]["action"],
34+
defaults: ContentAccessDefaultsInput = {},
35+
) =>
36+
decideContentAccess(
37+
actor,
38+
contentAccessRequirements({ action, chain, defaults, family: "f" }),
39+
).allowed;
40+
41+
describe("audiences", () => {
42+
it.each<[string, ContentAccessAudience, ContentAccessActor, boolean]>([
43+
["everyone lets a guest in", { mode: "everyone" }, GUEST, true],
44+
["members keeps a guest out", MEMBERS, GUEST, false],
45+
["members lets any member in", MEMBERS, member([2]), true],
46+
["roles keeps a guest out", ROLES_4_5, GUEST, false],
47+
[
48+
"roles keeps a member without the roles out",
49+
ROLES_4_5,
50+
member([2]),
51+
false,
52+
],
53+
// Primary and secondary roles are one list: any one of them is enough.
54+
[
55+
"roles lets in a member with any listed role",
56+
ROLES_4_5,
57+
member([2, 5]),
58+
true,
59+
],
60+
[
61+
"roles with no role left lets nobody in",
62+
CONTENT_ACCESS_DENY_ALL,
63+
member([4]),
64+
false,
65+
],
66+
])("%s", (_label, audience, actor, expected) => {
67+
expect(
68+
allows(actor, [{ id: "1", overrides: { view: audience } }], "view"),
69+
).toBe(expected);
70+
});
71+
72+
it("lets a root role past every rule, deny-all included", () => {
73+
const chain = [
74+
{
75+
id: "1",
76+
overrides: {
77+
downloadAttachments: CONTENT_ACCESS_DENY_ALL,
78+
view: CONTENT_ACCESS_DENY_ALL,
79+
},
80+
},
81+
];
82+
83+
expect(
84+
decideContentAccess(
85+
member([], true),
86+
contentAccessRequirements({
87+
action: "downloadAttachments",
88+
chain,
89+
defaults: {},
90+
family: "f",
91+
}),
92+
),
93+
).toEqual({ allowed: true, reason: "root" });
94+
});
95+
});
96+
97+
describe("prerequisites", () => {
98+
// Every action open except the one named: the action and everything that
99+
// needs it are closed to a guest, the rest stay open.
100+
it.each([
101+
[
102+
"view",
103+
{ comment: false, downloadAttachments: false, read: false, view: false },
104+
],
105+
[
106+
"read",
107+
{ comment: false, downloadAttachments: false, read: false, view: true },
108+
],
109+
[
110+
"comment",
111+
{ comment: false, downloadAttachments: true, read: true, view: true },
112+
],
113+
[
114+
"downloadAttachments",
115+
{ comment: true, downloadAttachments: false, read: true, view: true },
116+
],
117+
] as const)(
118+
"closing %s to guests closes what needs it",
119+
(closed, expected) => {
120+
const chain = [{ id: "1", overrides: { [closed]: MEMBERS } }];
121+
122+
expect({
123+
comment: allows(GUEST, chain, "comment"),
124+
downloadAttachments: allows(GUEST, chain, "downloadAttachments"),
125+
read: allows(GUEST, chain, "read"),
126+
view: allows(GUEST, chain, "view"),
127+
}).toEqual(expected);
128+
},
129+
);
130+
131+
it("keeps out a role allowed to comment but not to read", () => {
132+
const chain = [
133+
{
134+
id: "1",
135+
overrides: {
136+
comment: ROLES_4_5,
137+
read: { mode: "roles", roleIds: [9] },
138+
},
139+
},
140+
] satisfies ContentAccessScopeLayer[];
141+
142+
expect(allows(member([4]), chain, "comment")).toBe(false);
143+
});
144+
});
145+
146+
describe("readContentAccessOverrides", () => {
147+
it("fails closed on a stored rule that no longer parses", () => {
148+
expect(
149+
readContentAccessOverrides({
150+
comment: { mode: "vip" },
151+
read: { mode: "roles" },
152+
view: { mode: "members" },
153+
}),
154+
).toEqual({
155+
comment: CONTENT_ACCESS_DENY_ALL,
156+
read: CONTENT_ACCESS_DENY_ALL,
157+
view: { mode: "members" },
158+
});
159+
});
160+
161+
it.each([null, "members", ["view"], 7])(
162+
"reads %j as no rules at all",
163+
value => {
164+
expect(readContentAccessOverrides(value)).toEqual({});
165+
},
166+
);
167+
168+
it("leaves an action without a rule to inherit", () => {
169+
expect(
170+
readContentAccessOverrides({ read: null, view: { mode: "everyone" } }),
171+
).toEqual({ view: { mode: "everyone" } });
172+
});
173+
});
174+
175+
describe("resolveContentAccessRules", () => {
176+
it("takes each action from the scope, the nearest ancestor, the defaults, then everyone", () => {
177+
const { policy, sources } = resolveContentAccessRules({
178+
chain: [
179+
{ id: "child", overrides: { view: ROLES_4_5 } },
180+
{ id: "parent", overrides: { read: MEMBERS, view: MEMBERS } },
181+
{ id: "root", overrides: { read: CONTENT_ACCESS_DENY_ALL } },
182+
],
183+
defaults: { comment: { mode: "members" } },
184+
});
185+
186+
expect(policy).toEqual({
187+
comment: MEMBERS,
188+
downloadAttachments: { mode: "everyone" },
189+
read: MEMBERS,
190+
view: ROLES_4_5,
191+
});
192+
expect(sources).toEqual({
193+
comment: { kind: "default" },
194+
downloadAttachments: { kind: "fallback" },
195+
read: { kind: "scope", scopeId: "parent" },
196+
view: { kind: "scope", scopeId: "child" },
197+
});
198+
});
199+
});
200+
201+
describe("resolveContentAccessScopeInput", () => {
202+
it.each([
203+
[undefined, { defaults: {}, enabled: false }],
204+
[false, { defaults: {}, enabled: false }],
205+
[true, { defaults: {}, enabled: true }],
206+
[
207+
{ defaults: { comment: { mode: "members" } } },
208+
{ defaults: { comment: { mode: "members" } }, enabled: true },
209+
],
210+
] as const)("resolves %j", (input, expected) => {
211+
expect(resolveContentAccessScopeInput(input)).toEqual(expected);
212+
});
213+
214+
it("refuses a roles default, which only an installation can name", () => {
215+
expect(() =>
216+
resolveContentAccessScopeInput({
217+
defaults: {
218+
read: { mode: "roles", roleIds: [1] },
219+
} as unknown as ContentAccessDefaultsInput,
220+
}),
221+
).toThrow(/roles are chosen in the AdminCP/);
222+
});
223+
});

0 commit comments

Comments
 (0)