mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
feat(auth): complete scoped board key lifecycle
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
08adecda83
commit
d262fb5f0d
5 files changed
+377
-29
No files matched your search
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerTokenCommands } from "../commands/client/token.js";
|
||||
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const SECOND_COMPANY_ID = "33333333-3333-4333-8333-333333333333";
|
||||
const AGENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function createProgram(): Command {
|
||||
@@ -222,6 +223,77 @@ describe("token commands", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the finite 30-day default and the selected shared scope preset", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-23T00:00:00.000Z"));
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: "board-key-1",
|
||||
name: "reader",
|
||||
token: "pcp_board_plaintext",
|
||||
createdAt: "2026-05-23T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: "2026-06-22T00:00:00.000Z",
|
||||
}), { status: 201 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"token", "board", "create",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
"--scope-company-id", SECOND_COMPANY_ID,
|
||||
"--scope-preset", "read_only",
|
||||
"--name", "reader",
|
||||
"--json",
|
||||
], { from: "user" });
|
||||
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
|
||||
name: "reader",
|
||||
expiresAt: "2026-06-22T00:00:00.000Z",
|
||||
scopeConfig: {
|
||||
version: 1,
|
||||
kind: "scoped",
|
||||
companyIds: [COMPANY_ID, SECOND_COMPANY_ID],
|
||||
instanceCapabilities: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a custom scope only through the shared strict validator", async () => {
|
||||
const customScope = {
|
||||
version: 1,
|
||||
kind: "scoped",
|
||||
companyIds: [COMPANY_ID],
|
||||
permissions: ["issues:read", "issues:write"],
|
||||
instanceCapabilities: [],
|
||||
};
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: "board-key-1",
|
||||
name: "issue-bot",
|
||||
token: "pcp_board_plaintext",
|
||||
createdAt: "2026-05-23T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
expiresAt: "2026-06-22T00:00:00.000Z",
|
||||
}), { status: 201 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
await createProgram().parseAsync([
|
||||
"token", "board", "create",
|
||||
"--api-base", "http://localhost:3100",
|
||||
"--api-key", "board-token",
|
||||
"--company-id", COMPANY_ID,
|
||||
"--scope-config-json", JSON.stringify(customScope),
|
||||
"--name", "issue-bot",
|
||||
"--json",
|
||||
], { from: "user" });
|
||||
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)).scopeConfig).toEqual(customScope);
|
||||
});
|
||||
|
||||
it("lists and revokes board tokens", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
BOARD_API_KEY_SCOPE_PRESETS,
|
||||
boardApiKeyScopeConfigSchema,
|
||||
createAgentKeySchema,
|
||||
createBoardApiKeySchema,
|
||||
type Agent,
|
||||
type BoardApiKeyScopeConfig,
|
||||
type BoardApiKeyScopePreset,
|
||||
type BoardApiKeyStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
@@ -27,6 +31,9 @@ interface BoardTokenOptions extends BaseClientOptions {
|
||||
expiresAt?: string;
|
||||
ttlDays?: string;
|
||||
neverExpires?: boolean;
|
||||
scopePreset?: string;
|
||||
scopeCompanyId?: string[];
|
||||
scopeConfigJson?: string;
|
||||
}
|
||||
|
||||
interface CreatedAgentKey {
|
||||
@@ -57,6 +64,10 @@ interface CreatedBoardKey {
|
||||
interface BoardKeyRow {
|
||||
id: string;
|
||||
name: string;
|
||||
tokenPrefix: string | null;
|
||||
scopeConfig: BoardApiKeyScopeConfig | null;
|
||||
legacyUnrestricted: boolean;
|
||||
status: BoardApiKeyStatus;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
@@ -154,21 +165,29 @@ export function registerTokenCommands(program: Command): void {
|
||||
.option("--expires-at <iso8601>", "Expiration timestamp")
|
||||
.option("--ttl-days <days>", "Expiration in days from now")
|
||||
.option("--never-expires", "Create a non-expiring key")
|
||||
.option(
|
||||
"--scope-preset <preset>",
|
||||
"Scope preset (read_only|company_automation|full_instance_admin)",
|
||||
)
|
||||
.option(
|
||||
"--scope-company-id <id>",
|
||||
"Additional company ID to pin; may be repeated",
|
||||
collectOptionValue,
|
||||
[] as string[],
|
||||
)
|
||||
.option(
|
||||
"--scope-config-json <json>",
|
||||
"Custom strict BoardApiKeyScopeConfig JSON (cannot be combined with preset/additional company flags)",
|
||||
)
|
||||
.action(async (opts: BoardTokenOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||
const expiresAt = resolveBoardKeyExpiresAt(opts);
|
||||
const preset = BOARD_API_KEY_SCOPE_PRESETS.company_automation;
|
||||
const scopeConfig = resolveBoardKeyScopeConfig(opts, ctx.companyId as string);
|
||||
const payload = createBoardApiKeySchema.parse({
|
||||
name: opts.name,
|
||||
expiresAt,
|
||||
scopeConfig: {
|
||||
version: 1,
|
||||
kind: "scoped",
|
||||
companyIds: [ctx.companyId as string],
|
||||
permissions: [...preset.permissions],
|
||||
instanceCapabilities: [...preset.instanceCapabilities],
|
||||
},
|
||||
scopeConfig,
|
||||
});
|
||||
const key = await ctx.api.post<CreatedBoardKey>("/api/board-api-keys", payload);
|
||||
if (!key) throw new Error("Failed to create board API key");
|
||||
@@ -196,6 +215,10 @@ export function registerTokenCommands(program: Command): void {
|
||||
console.log(formatInlineRecord({
|
||||
id: key.id,
|
||||
name: key.name,
|
||||
tokenPrefix: key.tokenPrefix,
|
||||
status: key.status,
|
||||
legacyUnrestricted: key.legacyUnrestricted,
|
||||
scope: summarizeBoardKeyScope(key.scopeConfig),
|
||||
createdAt: key.createdAt,
|
||||
lastUsedAt: key.lastUsedAt,
|
||||
expiresAt: key.expiresAt,
|
||||
@@ -241,6 +264,14 @@ async function resolveAgent(api: { get<T>(path: string): Promise<T | null> }, co
|
||||
}
|
||||
|
||||
function resolveBoardKeyExpiresAt(opts: BoardTokenOptions): string | null | undefined {
|
||||
const selectedExpiryOptions = [
|
||||
Boolean(opts.neverExpires),
|
||||
Boolean(opts.expiresAt?.trim()),
|
||||
Boolean(opts.ttlDays?.trim()),
|
||||
].filter(Boolean).length;
|
||||
if (selectedExpiryOptions > 1) {
|
||||
throw new Error("Choose only one of --expires-at, --ttl-days, or --never-expires");
|
||||
}
|
||||
if (opts.neverExpires) return null;
|
||||
if (opts.expiresAt?.trim()) {
|
||||
const date = new Date(opts.expiresAt.trim());
|
||||
@@ -252,5 +283,72 @@ function resolveBoardKeyExpiresAt(opts: BoardTokenOptions): string | null | unde
|
||||
if (!Number.isFinite(days) || days <= 0) throw new Error(`Invalid --ttl-days value: ${opts.ttlDays}`);
|
||||
return new Date(Date.now() + Math.floor(days * 24 * 60 * 60 * 1000)).toISOString();
|
||||
}
|
||||
return undefined;
|
||||
return new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
const BOARD_SCOPE_PRESET_ALIASES: Record<string, BoardApiKeyScopePreset> = {
|
||||
read_only: "read_only",
|
||||
"read-only": "read_only",
|
||||
company_automation: "company_automation",
|
||||
"company-automation": "company_automation",
|
||||
full_instance_admin: "full_instance_admin",
|
||||
"full-instance-admin": "full_instance_admin",
|
||||
};
|
||||
|
||||
function resolveBoardKeyScopeConfig(
|
||||
opts: BoardTokenOptions,
|
||||
contextCompanyId: string,
|
||||
): BoardApiKeyScopeConfig {
|
||||
const additionalCompanyIds = opts.scopeCompanyId ?? [];
|
||||
if (opts.scopeConfigJson?.trim()) {
|
||||
if (opts.scopePreset?.trim() || additionalCompanyIds.length > 0) {
|
||||
throw new Error("--scope-config-json cannot be combined with --scope-preset or --scope-company-id");
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(opts.scopeConfigJson);
|
||||
} catch {
|
||||
throw new Error("Invalid --scope-config-json value: expected JSON");
|
||||
}
|
||||
const scopeConfig = boardApiKeyScopeConfigSchema.parse(parsed);
|
||||
if (!scopeConfig.companyIds.includes(contextCompanyId)) {
|
||||
throw new Error("--scope-config-json must include --company-id in companyIds");
|
||||
}
|
||||
return scopeConfig;
|
||||
}
|
||||
|
||||
const requestedPreset = opts.scopePreset?.trim() || "company_automation";
|
||||
const presetKey = BOARD_SCOPE_PRESET_ALIASES[requestedPreset];
|
||||
if (!presetKey) {
|
||||
throw new Error(
|
||||
`Invalid --scope-preset value: ${requestedPreset}. Expected read_only, company_automation, or full_instance_admin`,
|
||||
);
|
||||
}
|
||||
const preset = BOARD_API_KEY_SCOPE_PRESETS[presetKey];
|
||||
return boardApiKeyScopeConfigSchema.parse({
|
||||
version: 1,
|
||||
kind: "scoped",
|
||||
companyIds: [contextCompanyId, ...additionalCompanyIds],
|
||||
permissions: [...preset.permissions],
|
||||
instanceCapabilities: [...preset.instanceCapabilities],
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeBoardKeyScope(scopeConfig: BoardApiKeyScopeConfig | null): string {
|
||||
if (!scopeConfig) return "legacy_unrestricted";
|
||||
const matchingPreset = (Object.entries(BOARD_API_KEY_SCOPE_PRESETS) as Array<
|
||||
[BoardApiKeyScopePreset, (typeof BOARD_API_KEY_SCOPE_PRESETS)[BoardApiKeyScopePreset]]
|
||||
>).find(([, preset]) =>
|
||||
preset.permissions.length === scopeConfig.permissions.length
|
||||
&& preset.permissions.every((permission, index) => permission === scopeConfig.permissions[index])
|
||||
&& preset.instanceCapabilities.length === scopeConfig.instanceCapabilities.length
|
||||
&& preset.instanceCapabilities.every(
|
||||
(capability, index) => capability === scopeConfig.instanceCapabilities[index],
|
||||
));
|
||||
const label = matchingPreset?.[0] ?? "custom";
|
||||
return `${label}:${scopeConfig.companyIds.length}_companies`;
|
||||
}
|
||||
|
||||
function collectOptionValue(value: string, previous: string[]): string[] {
|
||||
return [...previous, value];
|
||||
}
|
||||
@@ -384,6 +384,27 @@ describe.sequential("cli auth routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential("rejects omitted and null board API key scopes before creation", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [COMPANY_ID],
|
||||
});
|
||||
|
||||
const omitted = await request(app)
|
||||
.post("/api/board-api-keys")
|
||||
.send({ name: "missing-scope" });
|
||||
const explicitNull = await request(app)
|
||||
.post("/api/board-api-keys")
|
||||
.send({ name: "null-scope", scopeConfig: null });
|
||||
|
||||
expect(omitted.status).toBe(400);
|
||||
expect(explicitNull.status).toBe(400);
|
||||
expect(mockBoardAuthService.createNamedBoardApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.sequential("lists and revokes named board API keys for the current board user", async () => {
|
||||
const keyId = "55555555-5555-4555-8555-555555555555";
|
||||
mockBoardAuthService.listBoardApiKeys.mockResolvedValue([
|
||||
@@ -424,6 +445,13 @@ describe.sequential("cli auth routes", () => {
|
||||
{ includeInactive: false },
|
||||
);
|
||||
|
||||
const inactiveListRes = await request(app).get("/api/board-api-keys?includeInactive=true");
|
||||
expect(inactiveListRes.status).toBe(200);
|
||||
expect(mockBoardAuthService.listBoardApiKeys).toHaveBeenLastCalledWith(
|
||||
"user-1",
|
||||
{ includeInactive: true },
|
||||
);
|
||||
|
||||
const revokeRes = await request(app).delete(`/api/board-api-keys/${keyId}`);
|
||||
expect(revokeRes.status).toBe(200);
|
||||
expect(revokeRes.body).toEqual({ ok: true, keyId });
|
||||
@@ -436,6 +464,48 @@ describe.sequential("cli auth routes", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.sequential("conceals a non-owned board API key during revoke", async () => {
|
||||
const keyId = "55555555-5555-4555-8555-555555555555";
|
||||
mockBoardAuthService.getBoardApiKeyForUser.mockResolvedValue(null);
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [COMPANY_ID],
|
||||
});
|
||||
|
||||
const res = await request(app).delete(`/api/board-api-keys/${keyId}`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockBoardAuthService.getBoardApiKeyForUser).toHaveBeenCalledWith(keyId, "user-1");
|
||||
expect(mockBoardAuthService.revokeBoardApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.sequential("treats re-revoking an owned inactive key as idempotent", async () => {
|
||||
const keyId = "55555555-5555-4555-8555-555555555555";
|
||||
mockBoardAuthService.getBoardApiKeyForUser.mockResolvedValue({
|
||||
id: keyId,
|
||||
userId: "user-1",
|
||||
name: "external-admin",
|
||||
revokedAt: new Date("2026-08-15T12:00:00.000Z"),
|
||||
});
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [COMPANY_ID],
|
||||
});
|
||||
|
||||
const res = await request(app).delete(`/api/board-api-keys/${keyId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true, keyId });
|
||||
expect(mockBoardAuthService.revokeBoardApiKey).not.toHaveBeenCalled();
|
||||
expect(mockLogActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.sequential("rejects malformed board API key IDs before database lookup", async () => {
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
|
||||
+26
-19
@@ -2942,27 +2942,34 @@ export function accessRoutes(
|
||||
}
|
||||
const key = await boardAuth.getBoardApiKeyForUser(keyId, req.actor.userId);
|
||||
if (!key) throw notFound("Board API key not found");
|
||||
const revoked = await boardAuth.revokeBoardApiKey(key.id);
|
||||
if (!revoked) throw notFound("Board API key not found");
|
||||
const revoked = key.revokedAt ? key : await boardAuth.revokeBoardApiKey(key.id);
|
||||
if (!revoked) {
|
||||
// A concurrent owned revoke is still success. Re-read through the same
|
||||
// owner-scoped projection so deletion and non-ownership stay concealed.
|
||||
const concurrentlyRevoked = await boardAuth.getBoardApiKeyForUser(keyId, req.actor.userId);
|
||||
if (!concurrentlyRevoked?.revokedAt) throw notFound("Board API key not found");
|
||||
}
|
||||
|
||||
const companyIds = await boardAuth.resolveBoardActivityCompanyIds({
|
||||
userId: req.actor.userId,
|
||||
boardApiKeyId: key.id,
|
||||
});
|
||||
for (const companyId of companyIds) {
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId,
|
||||
action: "board_api_key.revoked",
|
||||
entityType: "user",
|
||||
entityId: req.actor.userId,
|
||||
details: {
|
||||
boardApiKeyId: key.id,
|
||||
name: key.name,
|
||||
revokedVia: "board_api_key_lifecycle",
|
||||
},
|
||||
if (!key.revokedAt && revoked) {
|
||||
const companyIds = await boardAuth.resolveBoardActivityCompanyIds({
|
||||
userId: req.actor.userId,
|
||||
boardApiKeyId: key.id,
|
||||
});
|
||||
for (const companyId of companyIds) {
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId,
|
||||
action: "board_api_key.revoked",
|
||||
entityType: "user",
|
||||
entityId: req.actor.userId,
|
||||
details: {
|
||||
boardApiKeyId: key.id,
|
||||
name: key.name,
|
||||
revokedVia: "board_api_key_lifecycle",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true, keyId: key.id });
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { BOARD_API_KEY_SCOPE_PRESETS } from "@paperclipai/shared";
|
||||
import { boardAuthService } from "./board-auth.js";
|
||||
|
||||
const COMPANY_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const SCOPE_CONFIG = {
|
||||
version: 1 as const,
|
||||
kind: "scoped" as const,
|
||||
companyIds: [COMPANY_ID],
|
||||
permissions: [...BOARD_API_KEY_SCOPE_PRESETS.company_automation.permissions],
|
||||
instanceCapabilities: [] as const,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("boardAuthService touchBoardApiKey", () => {
|
||||
it("retries the audit write after a transient failure", async () => {
|
||||
const writes = [
|
||||
@@ -45,3 +59,90 @@ describe("boardAuthService touchBoardApiKey", () => {
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("boardAuthService board API key lifecycle", () => {
|
||||
it("stores only a hash, captures a safe prefix, and defaults expiry to 30 days", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-15T12:00:00.000Z"));
|
||||
let inserted: Record<string, unknown> | undefined;
|
||||
const insert = vi.fn(() => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
inserted = values;
|
||||
return {
|
||||
returning: () => Promise.resolve([{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
...values,
|
||||
legacyUnrestricted: false,
|
||||
createdAt: new Date("2026-08-15T12:00:00.000Z"),
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
}]),
|
||||
};
|
||||
},
|
||||
}));
|
||||
const service = boardAuthService({ insert } as unknown as Db);
|
||||
|
||||
const created = await service.createNamedBoardApiKey({
|
||||
userId: "user-1",
|
||||
name: "automation",
|
||||
scopeConfig: SCOPE_CONFIG,
|
||||
});
|
||||
|
||||
expect(created.token).toMatch(/^pcp_board_/);
|
||||
expect(inserted).toMatchObject({
|
||||
userId: "user-1",
|
||||
name: "automation",
|
||||
tokenPrefix: created.token.slice(0, "pcp_board_".length + 12),
|
||||
scopeConfig: SCOPE_CONFIG,
|
||||
expiresAt: new Date("2026-09-14T12:00:00.000Z"),
|
||||
});
|
||||
expect(inserted?.keyHash).not.toBe(created.token);
|
||||
expect(created.expiresAt).toEqual(new Date("2026-09-14T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("selects only safe metadata and derives status for list responses", async () => {
|
||||
const projectionKeys: string[][] = [];
|
||||
const select = vi.fn((projection: Record<string, unknown>) => {
|
||||
projectionKeys.push(Object.keys(projection));
|
||||
return {
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: () => Promise.resolve([{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "automation",
|
||||
tokenPrefix: "pcp_board_123456789abc",
|
||||
scopeConfig: SCOPE_CONFIG,
|
||||
legacyUnrestricted: false,
|
||||
createdAt: new Date("2026-08-15T12:00:00.000Z"),
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
revokedAt: null,
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
});
|
||||
const service = boardAuthService({ select } as unknown as Db);
|
||||
|
||||
const rows = await service.listBoardApiKeys("user-1", { includeInactive: true });
|
||||
|
||||
expect(projectionKeys[0]).toEqual([
|
||||
"id",
|
||||
"name",
|
||||
"tokenPrefix",
|
||||
"scopeConfig",
|
||||
"legacyUnrestricted",
|
||||
"createdAt",
|
||||
"lastUsedAt",
|
||||
"revokedAt",
|
||||
"expiresAt",
|
||||
]);
|
||||
expect(rows[0]).toMatchObject({
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
status: "never_expires",
|
||||
});
|
||||
expect(rows[0]).not.toHaveProperty("userId");
|
||||
expect(rows[0]).not.toHaveProperty("keyHash");
|
||||
expect(rows[0]).not.toHaveProperty("token");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user