Skip to content
Open
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
172 changes: 168 additions & 4 deletions packages/business/__tests__/platform-credential-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,37 @@ vi.mock("@chatbotx.io/database/client", () => ({
eq: vi.fn(),
isNull: vi.fn(),
}))
const credentialSchemas = {
instagram: { name: "instagram-schema" },
messenger: { name: "messenger-schema" },
}
const credentialPublicSchemas = {
messenger: { parse: vi.fn((value: unknown) => value) },
}
const credentialEncryptedSchema = {
parse: vi.fn((value: unknown) => value),
}
// Mirrors the real credentialAad from @chatbotx.io/database/partials inline
// rather than via importOriginal (importing real database-package modules in
// vitest mocks risks opening a DB connection). The exact-string assertions in
// the upsert tests below lock this format to the real implementation's.
const credentialAad = (props: {
userId?: string | null
type: string
livemode: boolean
}) =>
props.userId
? `user:${props.userId}:${props.type}:${props.livemode}`
: `platform:${props.type}:${props.livemode}`
vi.mock("@chatbotx.io/database/partials", () => ({
credentialEncryptedSchema: {},
credentialPublicSchemas: {},
credentialSchemas: {},
credentialAad,
credentialEncryptedSchema,
credentialPublicSchemas,
credentialSchemas,
}))
vi.mock("@chatbotx.io/database/schema", () => ({ platformCredentialModel: {} }))
vi.mock("@chatbotx.io/encryption", () => ({ encryptUtils: {} }))
const encryptUtils = { decryptObject: vi.fn(), encryptObject: vi.fn() }
vi.mock("@chatbotx.io/encryption", () => ({ encryptUtils }))
vi.mock("@chatbotx.io/redis", () => ({
invalidateCacheByTags: vi.fn(async () => undefined),
withCache: vi.fn(async (_key: string, fn: () => unknown) => fn()),
Expand All @@ -39,6 +63,9 @@ beforeEach(() => {

afterEach(() => {
vi.restoreAllMocks()
encryptUtils.decryptObject.mockReset()
encryptUtils.encryptObject.mockReset()
credentialEncryptedSchema.parse.mockClear()
})

describe("resolveForOwner", () => {
Expand Down Expand Up @@ -229,3 +256,140 @@ describe("resolvePlatformAppAccessToken", () => {
).resolves.toBeUndefined()
})
})

describe("_decrypt", () => {
// decryptObject takes no aad argument: it reads the aad off the blob
// itself (see packages/encryption/src/encryption.ts decryptText), so
// `_decrypt` doesn't need to re-derive the row-scoped aad the writers
// stamped — it just passes the blob and schema through.
test("decrypts a platform-scoped row", async () => {
encryptUtils.decryptObject.mockResolvedValue({ clientId: "client-1" })

await expect(
(platformCredentialService as never)._decrypt({
id: "platform-1",
userId: null,
type: "instagram",
livemode: false,
value: { encrypted: true },
publicConfig: { clientId: "client-1" },
createdAt: new Date("2026-08-13T00:00:00.000Z"),
updatedAt: new Date("2026-08-13T00:00:00.000Z"),
}),
).resolves.toEqual(
expect.objectContaining({
id: "platform-1",
userId: null,
type: "instagram",
config: { clientId: "client-1" },
}),
)

expect(encryptUtils.decryptObject).toHaveBeenCalledTimes(1)
expect(encryptUtils.decryptObject).toHaveBeenCalledWith(
{ encrypted: true },
credentialSchemas.instagram,
)
})

test("decrypts a user-scoped row", async () => {
encryptUtils.decryptObject.mockResolvedValue({ clientId: "client-2" })

await expect(
(platformCredentialService as never)._decrypt({
id: "user-1",
userId: "owner-1",
type: "messenger",
livemode: true,
value: { encrypted: true },
publicConfig: { clientId: "client-2" },
createdAt: new Date("2026-08-13T00:00:00.000Z"),
updatedAt: new Date("2026-08-13T00:00:00.000Z"),
}),
).resolves.toEqual(
expect.objectContaining({
id: "user-1",
userId: "owner-1",
type: "messenger",
config: { clientId: "client-2" },
}),
)

expect(encryptUtils.decryptObject).toHaveBeenCalledTimes(1)
expect(encryptUtils.decryptObject).toHaveBeenCalledWith(
{ encrypted: true },
credentialSchemas.messenger,
)
})
})

// Fakes a minimal chainable drizzle-style tx sufficient for
// upsertForUser/upsertPlatform's `.insert().values().onConflictDoUpdate()`
// call shape, without needing a real DB client.
const fakeTx = () => {
const tx = {
insert: vi.fn(() => tx),
values: vi.fn(() => tx),
onConflictDoUpdate: vi.fn(() => Promise.resolve()),
}
return tx as unknown as Parameters<
typeof platformCredentialService.upsertForUser
>[0]["tx"]
}

describe("upsertForUser / upsertPlatform write path", () => {
// Writers still stamp a row-derived aad at encrypt time — only decrypt
// stopped taking one. These assertions lock in that the derivation
// (user:<id>:<type>:<livemode> / platform:<type>:<livemode>) still reaches
// encryptObject unchanged.
test("upsertForUser encrypts with a row-derived aad", async () => {
encryptUtils.encryptObject.mockResolvedValue({
v: 1,
iv: "iv",
text: "text",
tag: "tag",
aad: "user:owner-1:messenger:false",
})
vi.spyOn(
platformCredentialService,
"invalidateCacheTags",
).mockResolvedValue(undefined)

await platformCredentialService.upsertForUser({
userId: "owner-1",
type: "messenger",
config: { clientId: "c", clientSecret: "s" } as never,
tx: fakeTx(),
})

expect(encryptUtils.encryptObject).toHaveBeenCalledWith(
{ clientId: "c", clientSecret: "s" },
"user:owner-1:messenger:false",
)
})

test("upsertPlatform encrypts with a platform-scoped aad", async () => {
encryptUtils.encryptObject.mockResolvedValue({
v: 1,
iv: "iv",
text: "text",
tag: "tag",
aad: "platform:messenger:false",
})
vi.spyOn(
platformCredentialService,
"invalidateCacheTags",
).mockResolvedValue(undefined)

await platformCredentialService.upsertPlatform({
type: "messenger",
config: { clientId: "c", clientSecret: "s" } as never,
tx: fakeTx(),
})

expect(encryptUtils.encryptObject).toHaveBeenCalledWith(
{ clientId: "c", clientSecret: "s" },
"platform:messenger:false",
)
})
})
5 changes: 3 additions & 2 deletions packages/business/src/platform-credential/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type CredentialByType,
type CredentialPublicByType,
type CredentialType,
credentialAad,
credentialEncryptedSchema,
credentialPublicSchemas,
credentialSchemas,
Expand Down Expand Up @@ -117,7 +118,7 @@ class PlatformCredentialService extends BaseService {
tx = db,
} = props
const publicConfig = this._publicConfig(type, config)
const aad = `user:${userId}:${type}:${livemode}`
const aad = credentialAad({ userId, type, livemode })
const value = await encryptUtils.encryptObject(config, aad)

await tx
Expand Down Expand Up @@ -242,7 +243,7 @@ class PlatformCredentialService extends BaseService {
}): Promise<void> {
const { type, config, livemode = false, tx = db } = props
const publicConfig = this._publicConfig(type, config)
const aad = `platform:${type}:${livemode}`
const aad = credentialAad({ type, livemode })
const value = await encryptUtils.encryptObject(config, aad)

await tx
Expand Down
86 changes: 86 additions & 0 deletions packages/database/__tests__/credential.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, test } from "vitest"
import {
credentialAad,
credentialEncryptedSchema,
giphyCredentialUpdateSchema,
googleCredentialUpdateSchema,
instagramCredentialUpdateSchema,
Expand Down Expand Up @@ -141,3 +143,87 @@ describe("credential update schemas", () => {
})
})
})

describe("credentialEncryptedSchema", () => {
const validBlob = {
v: 1 as const,
iv: "a".repeat(24),
text: "ciphertext-hex",
tag: "b".repeat(32),
aad: "user:1:messenger:false",
}

test("accepts a blob with a non-empty aad", () => {
const result = credentialEncryptedSchema.safeParse(validBlob)
expect(result.success).toBe(true)
})

// aad is optional here, matching the transport schema: the writers stamp
// it and decrypt reads it back off the blob, so no reader needs to
// reconstruct or pass one, and a blob with no aad is a legitimate case
// rather than an error.
test("accepts a blob missing aad entirely", () => {
const { aad: _aad, ...blobWithoutAad } = validBlob
const result = credentialEncryptedSchema.safeParse(blobWithoutAad)
expect(result.success).toBe(true)
})

test("kid remains optional for legacy blobs predating key versioning", () => {
const { kid: _kid, ...blobWithoutKid } = { ...validBlob, kid: "k1" }
const result = credentialEncryptedSchema.safeParse(blobWithoutKid)
expect(result.success).toBe(true)
})

// These mirror the length/non-empty checks encryptedDataSchema enforces on
// the same fields (packages/encryption/src/encryption.ts). credentialEncryptedSchema
// is intentionally standalone rather than derived from that schema, so
// without these checks a malformed iv/text/tag would pass this parse and
// only fail later inside hexToBytes/WebCrypto with an opaque error instead
// of a clear validation failure at the storage boundary.
test("rejects a blob with a wrong-length iv", () => {
const result = credentialEncryptedSchema.safeParse({
...validBlob,
iv: "a".repeat(10),
})
expect(result.success).toBe(false)
})

test("rejects a blob with an empty-string text", () => {
const result = credentialEncryptedSchema.safeParse({
...validBlob,
text: "",
})
expect(result.success).toBe(false)
})

test("rejects a blob with a wrong-length tag", () => {
const result = credentialEncryptedSchema.safeParse({
...validBlob,
tag: "b".repeat(10),
})
expect(result.success).toBe(false)
})
})

describe("credentialAad", () => {
// Pins the exact aad format at its source. All writers (upsertForUser,
// upsertPlatform, rotate-encryption-key.ts) go through this helper; the
// business-package tests assert the same strings from the consumer side.
test("derives a user-scoped aad when userId is present", () => {
expect(
credentialAad({ userId: "owner-1", type: "messenger", livemode: true }),
).toBe("user:owner-1:messenger:true")
})

test("derives a platform-scoped aad when userId is absent", () => {
expect(credentialAad({ type: "instagram", livemode: false })).toBe(
"platform:instagram:false",
)
})

test("treats a null userId as platform-scoped", () => {
expect(
credentialAad({ userId: null, type: "messenger", livemode: false }),
).toBe("platform:messenger:false")
})
})
37 changes: 32 additions & 5 deletions packages/database/scripts/rotate-encryption-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { db } from "../src/client"
import {
type CredentialByType,
type CredentialType,
credentialAad,
credentialEncryptedSchema,
credentialSchemas,
} from "../src/partials/credential"
Expand All @@ -41,15 +42,39 @@ const main = async (): Promise<void> => {

const rows = await db.select().from(platformCredentialModel)

const unparseable: (typeof rows)[number][] = []
const toRotate = rows.filter((row) => {
const result = credentialEncryptedSchema.safeParse(row.value)
return result.success && result.data.kid !== activeKid
if (!result.success) {
unparseable.push(row)
return false
}
return result.data.kid !== activeKid
})

console.log(
`Found ${toRotate.length} of ${rows.length} rows to rotate → kid="${activeKid}".`,
)

if (unparseable.length > 0) {
// These rows fail schema validation (e.g. an unexpected `v`, or a
// malformed iv/text/tag) and are excluded from `toRotate` above. Left
// unrotated, they become permanently undecryptable once
// ENCRYPTION_KEY_PREV is removed, so surface them loudly here rather
// than dropping them without a trace.
console.error(
`Warning: ${unparseable.length} row(s) failed schema validation and ` +
"will NOT be rotated. They will become undecryptable once " +
"ENCRYPTION_KEY_PREV is removed. Investigate before completing " +
"rotation:",
)
for (const row of unparseable) {
console.error(
` [id:${row.id} userId:${row.userId ?? "platform"} type:${row.type}]`,
)
}
}

if (isDryRun) {
console.log("Dry run — no changes written.")
return
Expand All @@ -62,14 +87,16 @@ const main = async (): Promise<void> => {
try {
const blob = credentialEncryptedSchema.parse(row.value)
const type = row.type as CredentialType
const aad = row.userId
? `user:${row.userId}:${type}:${row.livemode}`
: `platform:${type}:${row.livemode}`
const aad = credentialAad({
userId: row.userId,
type,
livemode: row.livemode,
})
const schema = credentialSchemas[type] as unknown as z.ZodType<
CredentialByType[CredentialType]
>

const config = await encryptUtils.decryptObject(blob, schema, aad)
const config = await encryptUtils.decryptObject(blob, schema)
const newValue = await encryptUtils.encryptObject(config, aad)

await db
Expand Down
Loading