/** * Capability Enforcement Integration Tests (v2) * * Tests the capability-based access gating in the v2 plugin context. * v2 always enforces capabilities - there's no "trusted mode" bypass. * */ import Database from "better-sqlite3"; import { Kysely, SqliteDialect, sql } from "kysely"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { runMigrations } from "../../../src/database/migrations/runner.js"; import { OptionsRepository } from "../../../src/database/repositories/options.js"; import { UserRepository } from "../../../src/database/repositories/user.js"; import type { Database as DbSchema } from "../../../src/database/types.js"; import { PluginContextFactory, createContentAccess, createContentAccessWithWrite, createHttpAccess, createUnrestrictedHttpAccess, createBlockedHttpAccess, createLogAccess, createStorageAccess, createKVAccess, createSiteInfo, createUrlHelper, createUserAccess, } from "../../../src/plugins/context.js"; import type { ResolvedPlugin } from "../../../src/plugins/types.js"; // Test regex patterns const NOT_ALLOWED_FETCH_REGEX = /not allowed to fetch from host/; const NO_ALLOWED_FETCH_REGEX = /not allowed to fetch/; const NO_NETWORK_FETCH_REGEX = /does not have the "network:request" capability/; const SEO_NOT_ENABLED_REGEX = /does not have SEO enabled/; /** * Create a minimal resolved plugin for testing */ function createTestPlugin(overrides: Partial = {}): ResolvedPlugin { return { id: "test-plugin", version: "1.0.0", capabilities: [], allowedHosts: [], storage: {}, admin: { pages: [], widgets: [], fieldWidgets: {}, }, hooks: {}, routes: {}, settings: undefined, ...overrides, }; } describe("Capability Enforcement Integration (v2)", () => { let db: Kysely; let sqliteDb: Database.Database; beforeEach(async () => { // Create in-memory SQLite database sqliteDb = new Database(":memory:"); db = new Kysely({ dialect: new SqliteDialect({ database: sqliteDb, }), }); // Run migrations await runMigrations(db); // Create test content table with actual field columns (not JSON data column) // The ContentRepository expects real columns for each field await sql` CREATE TABLE IF NOT EXISTS ec_posts ( id TEXT PRIMARY KEY, slug TEXT, status TEXT DEFAULT 'draft', author_id TEXT, primary_byline_id TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), published_at TEXT, deleted_at TEXT, version INTEGER DEFAULT 1, locale TEXT NOT NULL DEFAULT 'en', translation_group TEXT, title TEXT, content TEXT, UNIQUE(slug, locale) ) `.execute(db); // Insert test content with actual column values await sql` INSERT INTO ec_posts (id, slug, status, title, content, locale, translation_group) VALUES ('post-1', 'hello-world', 'published', 'Hello World', 'Content 1', 'en', 'post-1'), ('post-2', 'second-post', 'draft', 'Second Post', 'Content 2', 'en', 'post-2') `.execute(db); }); afterEach(async () => { await db.destroy(); sqliteDb.close(); }); describe("Content Access", () => { describe("createContentAccess (read-only)", () => { it("can read content by ID", async () => { const access = createContentAccess(db); const post = await access.get("posts", "post-1"); expect(post).not.toBeNull(); expect(post!.id).toBe("post-1"); expect(post!.data.title).toBe("Hello World"); }); it("can list content", async () => { const access = createContentAccess(db); const result = await access.list("posts"); expect(result.items).toHaveLength(2); expect(result.hasMore).toBe(false); }); it("narrows list results by where.status", async () => { const access = createContentAccess(db); const result = await access.list("posts", { where: { status: "published" } }); expect(result.items).toHaveLength(1); expect(result.items[0]!.id).toBe("post-1"); expect(result.items[0]!.status).toBe("published"); }); it("narrows list results by where.locale", async () => { await sql` INSERT INTO ec_posts (id, slug, status, title, content, locale, translation_group) VALUES ('post-3', 'bonjour', 'published', 'Bonjour', 'Contenu', 'fr', 'post-3') `.execute(db); const access = createContentAccess(db); const result = await access.list("posts", { where: { locale: "fr" } }); expect(result.items).toHaveLength(1); expect(result.items[0]!.id).toBe("post-3"); }); it("combines where.status and where.locale", async () => { await sql` INSERT INTO ec_posts (id, slug, status, title, content, locale, translation_group) VALUES ('post-3', 'bonjour', 'published', 'Bonjour', 'Contenu', 'fr', 'post-3'), ('post-4', 'brouillon', 'draft', 'Brouillon', 'WIP', 'fr', 'post-4') `.execute(db); const access = createContentAccess(db); const result = await access.list("posts", { where: { status: "published", locale: "fr" }, }); expect(result.items).toHaveLength(1); expect(result.items[0]!.id).toBe("post-3"); }); it("paginates consistently with where filters", async () => { // Three more published posts so the total published count is 4. // A limit of 2 should yield two pages: [2, 2] — never drafts. await sql` INSERT INTO ec_posts (id, slug, status, title, content, locale, translation_group) VALUES ('post-3', 'a', 'published', 'A', 'a', 'en', 'post-3'), ('post-4', 'b', 'published', 'B', 'b', 'en', 'post-4'), ('post-5', 'c', 'published', 'C', 'c', 'en', 'post-5') `.execute(db); const access = createContentAccess(db); const page1 = await access.list("posts", { limit: 2, where: { status: "published" }, }); expect(page1.items).toHaveLength(2); expect(page1.hasMore).toBe(true); for (const item of page1.items) expect(item.status).toBe("published"); const page2 = await access.list("posts", { limit: 2, cursor: page1.cursor, where: { status: "published" }, }); expect(page2.items).toHaveLength(2); expect(page2.hasMore).toBe(false); for (const item of page2.items) expect(item.status).toBe("published"); // No overlap between pages const ids = new Set([...page1.items, ...page2.items].map((i) => i.id)); expect(ids.size).toBe(4); // Drafts never surface expect(ids.has("post-2")).toBe(false); }); it("returns null for non-existent content", async () => { const access = createContentAccess(db); const post = await access.get("posts", "non-existent"); expect(post).toBeNull(); }); }); describe("createContentAccessWithWrite", () => { it("includes read methods", async () => { const access = createContentAccessWithWrite(db); expect(typeof access.get).toBe("function"); expect(typeof access.list).toBe("function"); }); it("includes write methods", async () => { const access = createContentAccessWithWrite(db); expect(typeof access.create).toBe("function"); expect(typeof access.update).toBe("function"); expect(typeof access.delete).toBe("function"); }); it("can create new content", async () => { const access = createContentAccessWithWrite(db); const created = await access.create("posts", { title: "New Post", content: "New content", }); expect(created.id).toBeDefined(); expect(created.data.title).toBe("New Post"); // Verify it was created const found = await access.get("posts", created.id); expect(found).not.toBeNull(); }); }); describe("SEO panel integration", () => { beforeEach(async () => { // Register the "posts" collection with SEO enabled so the plugin // content API routes `seo` writes to the core SEO panel. await sql` INSERT INTO _emdash_collections (slug, label, label_singular, has_seo) VALUES ('posts', 'Posts', 'Post', 1) `.execute(db); }); it("returns seo defaults from get() for SEO-enabled collections", async () => { const access = createContentAccess(db); const post = await access.get("posts", "post-1"); expect(post).not.toBeNull(); expect(post!.seo).toEqual({ title: null, description: null, image: null, canonical: null, noIndex: false, }); }); it("omits seo from get() for collections without SEO enabled", async () => { // Reset has_seo on posts so it behaves like a non-SEO collection await db .updateTable("_emdash_collections") .set({ has_seo: 0 }) .where("slug", "=", "posts") .execute(); const access = createContentAccess(db); const post = await access.get("posts", "post-1"); expect(post).not.toBeNull(); expect(post!.seo).toBeUndefined(); }); it("update() routes `seo` to the SEO panel instead of failing on missing column", async () => { const access = createContentAccessWithWrite(db); // Regression for #374: previously this threw // "SQLite error: no such column: seo" const updated = await access.update("posts", "post-1", { seo: { title: "Custom SEO Title", description: "A better meta description", canonical: "https://example.com/canonical", noIndex: false, }, }); expect(updated.seo).toEqual({ title: "Custom SEO Title", description: "A better meta description", image: null, canonical: "https://example.com/canonical", noIndex: false, }); // Verify it persisted via a subsequent read const fresh = await access.get("posts", "post-1"); expect(fresh!.seo?.title).toBe("Custom SEO Title"); expect(fresh!.seo?.description).toBe("A better meta description"); }); it("update() accepts field updates alongside seo in a single call", async () => { const access = createContentAccessWithWrite(db); const updated = await access.update("posts", "post-1", { title: "Updated Title", seo: { title: "SEO Title", description: "SEO Description", }, }); expect(updated.data.title).toBe("Updated Title"); expect(updated.seo?.title).toBe("SEO Title"); expect(updated.seo?.description).toBe("SEO Description"); }); it("update() only overwrites explicitly-set seo fields (partial updates)", async () => { const access = createContentAccessWithWrite(db); await access.update("posts", "post-1", { seo: { title: "Initial Title", description: "Initial Description" }, }); const updated = await access.update("posts", "post-1", { seo: { title: "Updated Title" }, }); expect(updated.seo?.title).toBe("Updated Title"); // description must not be clobbered by a partial update expect(updated.seo?.description).toBe("Initial Description"); }); it("create() routes `seo` to the SEO panel", async () => { const access = createContentAccessWithWrite(db); const created = await access.create("posts", { title: "New Post", content: "Body", seo: { title: "Brand New SEO", description: "New Description", }, }); expect(created.data.title).toBe("New Post"); expect(created.seo?.title).toBe("Brand New SEO"); expect(created.seo?.description).toBe("New Description"); const fresh = await access.get("posts", created.id); expect(fresh!.seo?.title).toBe("Brand New SEO"); }); it("update() throws when seo is provided on a collection without SEO enabled", async () => { // Disable SEO on posts await db .updateTable("_emdash_collections") .set({ has_seo: 0 }) .where("slug", "=", "posts") .execute(); const access = createContentAccessWithWrite(db); await expect( access.update("posts", "post-1", { seo: { title: "Won't work" }, }), ).rejects.toThrow(SEO_NOT_ENABLED_REGEX); }); it("list() hydrates seo for each item in SEO-enabled collections", async () => { const access = createContentAccessWithWrite(db); await access.update("posts", "post-1", { seo: { title: "Post One SEO" }, }); await access.update("posts", "post-2", { seo: { title: "Post Two SEO" }, }); const result = await access.list("posts"); expect(result.items).toHaveLength(2); const byId = new Map(result.items.map((i) => [i.id, i])); expect(byId.get("post-1")?.seo?.title).toBe("Post One SEO"); expect(byId.get("post-2")?.seo?.title).toBe("Post Two SEO"); }); }); }); describe("HTTP Access", () => { describe("createHttpAccess (with host restrictions)", () => { it("allows requests to allowed hosts", async () => { const http = createHttpAccess("test-plugin", ["example.com"]); // We can't actually make the request in tests, but we can verify // the function doesn't throw for allowed hosts expect(typeof http.fetch).toBe("function"); }); it("blocks requests to non-allowed hosts", async () => { const http = createHttpAccess("test-plugin", ["example.com"]); await expect(http.fetch("https://evil.com/api")).rejects.toThrow(NOT_ALLOWED_FETCH_REGEX); }); it("supports wildcard host patterns", { timeout: 15000 }, async () => { const http = createHttpAccess("test-plugin", ["*.example.com"]); // Should not throw for subdomains // (Can't test actual fetch, but verify pattern matching logic) await expect(http.fetch("https://api.example.com/test")).rejects.not.toThrow( NO_ALLOWED_FETCH_REGEX, ); }); }); describe("createBlockedHttpAccess", () => { it("always throws", async () => { const http = createBlockedHttpAccess("no-network-plugin"); await expect(http.fetch("https://example.com")).rejects.toThrow(NO_NETWORK_FETCH_REGEX); }); }); describe("createUnrestrictedHttpAccess", () => { it("returns an HttpAccess with a fetch function", () => { const http = createUnrestrictedHttpAccess("unrestricted-plugin"); expect(typeof http.fetch).toBe("function"); }); it("does not throw for any host", async () => { const http = createUnrestrictedHttpAccess("unrestricted-plugin"); // Can't make a real request in tests, but verify it doesn't throw a // host-validation error — it will throw a network error instead. await expect(http.fetch("https://any-host-at-all.example.com/test")).rejects.not.toThrow( NOT_ALLOWED_FETCH_REGEX, ); }); }); }); describe("Storage Access", () => { it("creates collection accessors from config", () => { const storage = createStorageAccess(db, "test-plugin", { events: { indexes: ["type"] }, cache: { indexes: ["key"] }, }); expect(storage.events).toBeDefined(); expect(storage.cache).toBeDefined(); }); it("provides full StorageCollection API", () => { const storage = createStorageAccess(db, "test-plugin", { items: { indexes: [] }, }); const collection = storage.items; expect(typeof collection.get).toBe("function"); expect(typeof collection.put).toBe("function"); expect(typeof collection.delete).toBe("function"); expect(typeof collection.exists).toBe("function"); expect(typeof collection.getMany).toBe("function"); expect(typeof collection.putMany).toBe("function"); expect(typeof collection.deleteMany).toBe("function"); expect(typeof collection.query).toBe("function"); expect(typeof collection.count).toBe("function"); }); it("isolates storage between plugins", async () => { const storage1 = createStorageAccess(db, "plugin-1", { items: { indexes: [] }, }); const storage2 = createStorageAccess(db, "plugin-2", { items: { indexes: [] }, }); await storage1.items.put("doc-1", { value: "from plugin 1" }); // Plugin 2 should not see plugin 1's data const fromPlugin2 = await storage2.items.get("doc-1"); expect(fromPlugin2).toBeNull(); // Plugin 1 should still see its data const fromPlugin1 = await storage1.items.get("doc-1"); expect(fromPlugin1).toEqual({ value: "from plugin 1" }); }); }); describe("KV Access", () => { it("prefixes keys with plugin ID", async () => { const optionsRepo = new OptionsRepository(db); const kv = createKVAccess(optionsRepo, "test-plugin"); await kv.set("my-key", { foo: "bar" }); // Verify the key is prefixed in the database const rawValue = await optionsRepo.get("plugin:test-plugin:my-key"); expect(rawValue).toEqual({ foo: "bar" }); }); it("isolates KV between plugins", async () => { const optionsRepo = new OptionsRepository(db); const kv1 = createKVAccess(optionsRepo, "plugin-1"); const kv2 = createKVAccess(optionsRepo, "plugin-2"); await kv1.set("shared-key", "value from 1"); await kv2.set("shared-key", "value from 2"); expect(await kv1.get("shared-key")).toBe("value from 1"); expect(await kv2.get("shared-key")).toBe("value from 2"); }); it("supports listing keys with prefix", async () => { const optionsRepo = new OptionsRepository(db); const kv = createKVAccess(optionsRepo, "test-plugin"); await kv.set("settings:theme", "dark"); await kv.set("settings:lang", "en"); await kv.set("cache:user-1", { name: "John" }); const settings = await kv.list("settings:"); expect(settings).toHaveLength(2); expect(settings.map((s) => s.key).toSorted()).toEqual(["settings:lang", "settings:theme"]); }); }); describe("Log Access", () => { it("prefixes messages with plugin ID", () => { const log = createLogAccess("test-plugin"); // These just verify the methods exist and don't throw expect(() => log.debug("test message")).not.toThrow(); expect(() => log.info("test message", { extra: "data" })).not.toThrow(); expect(() => log.warn("test warning")).not.toThrow(); expect(() => log.error("test error")).not.toThrow(); }); }); describe("PluginContextFactory", () => { it("creates context with capability-gated access", () => { const factory = new PluginContextFactory({ db }); const readOnlyPlugin = createTestPlugin({ id: "reader", capabilities: ["content:read"], }); const ctx = factory.createContext(readOnlyPlugin); // Content should be read-only (no create/update/delete) expect(ctx.content).toBeDefined(); expect(typeof ctx.content!.get).toBe("function"); expect(typeof ctx.content!.list).toBe("function"); expect("create" in ctx.content!).toBe(false); }); it("provides undefined content for plugins without capability", () => { const factory = new PluginContextFactory({ db }); const noContentPlugin = createTestPlugin({ id: "no-content", capabilities: ["network:request"], }); const ctx = factory.createContext(noContentPlugin); expect(ctx.content).toBeUndefined(); }); it("provides http for plugins with network:fetch", () => { const factory = new PluginContextFactory({ db }); const networkPlugin = createTestPlugin({ id: "network", capabilities: ["network:request"], allowedHosts: ["api.example.com"], }); const ctx = factory.createContext(networkPlugin); expect(ctx.http).toBeDefined(); expect(typeof ctx.http!.fetch).toBe("function"); }); it("provides undefined http for plugins without capability", () => { const factory = new PluginContextFactory({ db }); const noNetworkPlugin = createTestPlugin({ id: "no-network", capabilities: [], }); const ctx = factory.createContext(noNetworkPlugin); expect(ctx.http).toBeUndefined(); }); it("provides unrestricted http for plugins with network:fetch:any", () => { const factory = new PluginContextFactory({ db }); const plugin = createTestPlugin({ id: "unrestricted-network", capabilities: ["network:request:unrestricted", "network:request"], }); const ctx = factory.createContext(plugin); expect(ctx.http).toBeDefined(); expect(typeof ctx.http!.fetch).toBe("function"); }); it("prefers network:fetch:any over network:fetch when both present", async () => { const factory = new PluginContextFactory({ db }); const plugin = createTestPlugin({ id: "both-fetch", capabilities: ["network:request", "network:request:unrestricted"], allowedHosts: ["restricted.example.com"], }); const ctx = factory.createContext(plugin); expect(ctx.http).toBeDefined(); // With network:fetch:any, arbitrary hosts should not throw a host validation error await expect(ctx.http!.fetch("https://unrestricted.example.com/test")).rejects.not.toThrow( NOT_ALLOWED_FETCH_REGEX, ); }); it("always provides kv, storage, and log", () => { const factory = new PluginContextFactory({ db }); const minimalPlugin = createTestPlugin({ id: "minimal", capabilities: [], storage: { items: { indexes: [] }, }, }); const ctx = factory.createContext(minimalPlugin); expect(ctx.kv).toBeDefined(); expect(ctx.storage).toBeDefined(); expect(ctx.storage.items).toBeDefined(); expect(ctx.log).toBeDefined(); }); it("provides write:content access with create/update/delete", () => { const factory = new PluginContextFactory({ db }); const writePlugin = createTestPlugin({ id: "writer", capabilities: ["content:write"], }); const ctx = factory.createContext(writePlugin); expect(ctx.content).toBeDefined(); expect("create" in ctx.content!).toBe(true); expect("update" in ctx.content!).toBe(true); expect("delete" in ctx.content!).toBe(true); }); it("always provides site info", () => { const factory = new PluginContextFactory({ db }); const plugin = createTestPlugin({ id: "site-test", capabilities: [] }); const ctx = factory.createContext(plugin); expect(ctx.site).toBeDefined(); expect(typeof ctx.site.name).toBe("string"); expect(typeof ctx.site.url).toBe("string"); expect(typeof ctx.site.locale).toBe("string"); }); it("always provides url() helper", () => { const factory = new PluginContextFactory({ db, siteInfo: { siteUrl: "https://example.com" }, }); const plugin = createTestPlugin({ id: "url-test", capabilities: [] }); const ctx = factory.createContext(plugin); expect(typeof ctx.url).toBe("function"); expect(ctx.url("/posts")).toBe("https://example.com/posts"); }); it("provides users for plugins with read:users", () => { const factory = new PluginContextFactory({ db }); const plugin = createTestPlugin({ id: "user-reader", capabilities: ["users:read"], }); const ctx = factory.createContext(plugin); expect(ctx.users).toBeDefined(); expect(typeof ctx.users!.get).toBe("function"); expect(typeof ctx.users!.getByEmail).toBe("function"); expect(typeof ctx.users!.list).toBe("function"); }); it("provides undefined users for plugins without read:users", () => { const factory = new PluginContextFactory({ db }); const plugin = createTestPlugin({ id: "no-users", capabilities: [], }); const ctx = factory.createContext(plugin); expect(ctx.users).toBeUndefined(); }); }); describe("Site Info", () => { it("creates site info with all options", () => { const info = createSiteInfo({ siteName: "My Site", siteUrl: "https://example.com/", locale: "fr", }); expect(info.name).toBe("My Site"); expect(info.url).toBe("https://example.com"); // trailing slash stripped expect(info.locale).toBe("fr"); }); it("uses defaults for missing values", () => { const info = createSiteInfo({}); expect(info.name).toBe(""); expect(info.url).toBe(""); expect(info.locale).toBe("en"); }); it("strips trailing slash from URL", () => { const info = createSiteInfo({ siteUrl: "https://example.com/" }); expect(info.url).toBe("https://example.com"); }); }); describe("URL Helper", () => { it("creates absolute URLs from paths", () => { const url = createUrlHelper("https://example.com"); expect(url("/posts")).toBe("https://example.com/posts"); expect(url("/")).toBe("https://example.com/"); }); it("strips trailing slash from base URL", () => { const url = createUrlHelper("https://example.com/"); expect(url("/posts")).toBe("https://example.com/posts"); }); it("throws for paths not starting with /", () => { const url = createUrlHelper("https://example.com"); expect(() => url("posts")).toThrow('URL path must start with "/"'); }); it("works with empty base URL", () => { const url = createUrlHelper(""); expect(url("/posts")).toBe("/posts"); }); it("rejects protocol-relative paths (//)", () => { const url = createUrlHelper("https://example.com"); expect(() => url("//evil.com")).toThrow("protocol-relative"); }); it("rejects protocol-relative paths with empty base URL", () => { const url = createUrlHelper(""); expect(() => url("//evil.com/path")).toThrow("protocol-relative"); }); }); describe("User Access", () => { let userRepo: UserRepository; beforeEach(async () => { userRepo = new UserRepository(db); // Create test users with all 5 role levels await userRepo.create({ email: "admin@test.com", name: "Admin User", role: "admin" }); await userRepo.create({ email: "editor@test.com", name: "Editor User", role: "editor" }); await userRepo.create({ email: "author@test.com", name: "Author User", role: "author" }); await userRepo.create({ email: "contrib@test.com", name: "Contributor User", role: "contributor", }); await userRepo.create({ email: "sub@test.com", name: "Subscriber User", role: "subscriber", }); }); it("gets user by ID", async () => { const user = await userRepo.findByEmail("admin@test.com"); const access = createUserAccess(db); const result = await access.get(user!.id); expect(result).not.toBeNull(); expect(result!.email).toBe("admin@test.com"); expect(result!.name).toBe("Admin User"); expect(result!.role).toBe(50); // admin = 50 }); it("gets user by email", async () => { const access = createUserAccess(db); const result = await access.getByEmail("editor@test.com"); expect(result).not.toBeNull(); expect(result!.email).toBe("editor@test.com"); expect(result!.role).toBe(40); // editor = 40 }); it("returns null for non-existent user", async () => { const access = createUserAccess(db); expect(await access.get("non-existent")).toBeNull(); expect(await access.getByEmail("nobody@test.com")).toBeNull(); }); it("lists users", async () => { const access = createUserAccess(db); const result = await access.list(); expect(result.items).toHaveLength(5); // All users should have role as number for (const user of result.items) { expect(typeof user.role).toBe("number"); } }); it("excludes sensitive fields", async () => { const access = createUserAccess(db); const result = await access.list(); for (const user of result.items) { // UserInfo should only have: id, email, name, role, createdAt const keys = Object.keys(user); expect(keys).toContain("id"); expect(keys).toContain("email"); expect(keys).toContain("name"); expect(keys).toContain("role"); expect(keys).toContain("createdAt"); // Should NOT have sensitive fields expect(keys).not.toContain("avatarUrl"); expect(keys).not.toContain("emailVerified"); expect(keys).not.toContain("data"); expect(keys).not.toContain("password_hash"); } }); it("converts role strings to numeric levels", async () => { const access = createUserAccess(db); const admin = await access.getByEmail("admin@test.com"); const editor = await access.getByEmail("editor@test.com"); const subscriber = await access.getByEmail("sub@test.com"); expect(admin!.role).toBe(50); expect(editor!.role).toBe(40); expect(subscriber!.role).toBe(10); }); it("respects limit on list", async () => { const access = createUserAccess(db); const result = await access.list({ limit: 2 }); expect(result.items).toHaveLength(2); expect(result.nextCursor).toBeDefined(); }); it("clamps limit to maximum of 100", async () => { const access = createUserAccess(db); // Should not throw for large limits — just clamp const result = await access.list({ limit: 500 }); expect(result.items).toHaveLength(5); }); it("clamps negative limit to minimum of 1", async () => { const access = createUserAccess(db); // Negative limit should be clamped to 1, not passed through const result = await access.list({ limit: -999 }); expect(result.items).toHaveLength(1); }); it("preserves contributor (20) and author (30) roles", async () => { // beforeEach creates users via UserRepository with all 5 roles. // Verify that contributor (20) and author (30) survive the round-trip. const access = createUserAccess(db); const contributor = await access.getByEmail("contrib@test.com"); expect(contributor).not.toBeNull(); expect(contributor!.role).toBe(20); const author = await access.getByEmail("author@test.com"); expect(author).not.toBeNull(); expect(author!.role).toBe(30); }); it("filters users by exact role number", async () => { // beforeEach creates one user per role level (10, 20, 30, 40, 50) const access = createUserAccess(db); const contributors = await access.list({ role: 20 }); expect(contributors.items).toHaveLength(1); expect(contributors.items[0]!.email).toBe("contrib@test.com"); expect(contributors.items[0]!.role).toBe(20); const authors = await access.list({ role: 30 }); expect(authors.items).toHaveLength(1); expect(authors.items[0]!.email).toBe("author@test.com"); expect(authors.items[0]!.role).toBe(30); const admins = await access.list({ role: 50 }); expect(admins.items).toHaveLength(1); expect(admins.items[0]!.email).toBe("admin@test.com"); }); it("supports cursor-based pagination", async () => { const access = createUserAccess(db); const seen = new Set(); // Page through all 5 users one at a time let cursor: string | undefined; let pageCount = 0; // eslint-disable-next-line no-constant-condition while (true) { const page = await access.list({ limit: 1, cursor }); if (page.items.length === 0) break; expect(page.items).toHaveLength(1); const userId = page.items[0]!.id; expect(seen.has(userId)).toBe(false); // no duplicates seen.add(userId); pageCount++; if (!page.nextCursor) break; // last page cursor = page.nextCursor; } expect(seen.size).toBe(5); expect(pageCount).toBe(5); }); }); });