feat(twenty-server): introduce ENCRYPTION_KEY env var with versioned envelope (#20528)
## Summary
- Adds `ENCRYPTION_KEY` (primary) and `FALLBACK_ENCRYPTION_KEY`
(decrypt-only fallback for rotation) env vars to twenty-server, with
backward-compatible fallback to `APP_SECRET` when `ENCRYPTION_KEY` is
unset.
- Introduces a versioned ciphertext envelope `enc:v2:<keyId>:<base64>`
using AES-256-GCM with HKDF-SHA256 derived per-context keys. The 8-hex
`keyId` fingerprint lets every row identify which physical key encrypted
it, so rotation routes directly to primary or fallback without trial
decryption; GCM's auth tag gives true integrity (legacy CTR has none).
- Migrates `ConnectedAccountTokenEncryptionService` to the new envelope
and plumbs `workspaceId` through every caller, so per-workspace HKDF
context binds each row to its tenant.
The remaining encryption sites (`jwt-key-manager`, `config-storage`,
`postgres-credentials`, `application-variable`, TOTP) stay on the legacy
unprefixed CTR path and will be migrated in follow-up PRs. The
operator-facing rotation runbook is out of scope here.
### Format details
`enc:v{N}:{keyId}:{base64}` — `N=2` is the only version produced by new
writes (`v1` exists for backward-compatible decryption of existing
connected-account rows). `keyId =
sha256(rawKey).slice(0,4).toString('hex')`. The CHECK constraint on
`core.connectedAccount.{accessToken,refreshToken}` is relaxed from `LIKE
'enc:v1:%'` to `LIKE 'enc:v_:%'` so both versions pass.
### Key resolution
| `ENCRYPTION_KEY` | `FALLBACK_ENCRYPTION_KEY` | `APP_SECRET` | Encrypt
with | Decrypt try order |
|---|---|---|---|---|
| set | set | (any) | `ENCRYPTION_KEY` | match `keyId` → primary →
fallback |
| set | unset | (any) | `ENCRYPTION_KEY` | match `keyId` → primary |
| unset | set | set | `APP_SECRET` | match `keyId` → `APP_SECRET` →
fallback |
| unset | unset | set | `APP_SECRET` | match `keyId` → `APP_SECRET` |
| unset | unset | unset | startup error | n/a |
## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx jest
'secret-encryption|connected-account-token-encryption|connected-account-refresh-tokens|encrypt-connected-account-tokens|connection-provider-oauth-flow'`
— 87 tests pass
- [x] New `secret-encryption.service.versioned.spec.ts` covers: key
resolution table (no-key error, APP_SECRET fallback, ENCRYPTION_KEY
precedence), v2 round-trip with/without workspaceId, GCM tamper
rejection, workspaceId-mismatch rejection, keyId-based primary→fallback
routing, missing-key error names the fingerprint, v1 legacy decryption,
no-prefix legacy decryption, malformed envelope rejection.
- [x] Updated `connected-account-token-encryption.service.spec.ts`
covers workspaceId binding and HKDF context isolation.
- [x] Updated slow instance command spec verifies workspaceId is
threaded through encryption and the relaxed `enc:v_:%` LIKE pattern
matches both v1 and v2.
- [ ] Manual E2E: connect a Gmail account on a freshly deployed instance
with `APP_SECRET` only → confirm `core.connectedAccount.accessToken` is
`enc:v2:<keyId>:<base64>`.
- [ ] Manual E2E: rotate — set `ENCRYPTION_KEY=<new>` and
`FALLBACK_ENCRYPTION_KEY=<old APP_SECRET>`, restart, confirm
pre-rotation rows still decrypt and new rows carry the new `keyId`.
- [ ] Manual E2E: missing key — set `ENCRYPTION_KEY=<new>` without the
fallback, confirm decrypt error names the old `keyId` so the operator
can identify the missing key.
This commit is contained in:
+28
-35
@@ -1,11 +1,10 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { DataSource, QueryRunner } from 'typeorm';
|
||||
|
||||
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
|
||||
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
|
||||
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
|
||||
import {
|
||||
CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX,
|
||||
ConnectedAccountTokenEncryptionService,
|
||||
} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
|
||||
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
|
||||
|
||||
const BACKFILL_BATCH_SIZE = 500;
|
||||
|
||||
@@ -14,12 +13,18 @@ const ACCESS_TOKEN_CHECK_CONSTRAINT_NAME =
|
||||
const REFRESH_TOKEN_CHECK_CONSTRAINT_NAME =
|
||||
'CHK_connectedAccount_refreshToken_encrypted';
|
||||
|
||||
const V2_ENCRYPTED_LIKE_PATTERN = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}%`;
|
||||
|
||||
type ConnectedAccountTokenRow = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
};
|
||||
|
||||
const isPlaintext = (value: string | null): value is string =>
|
||||
isDefined(value) && !value.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX);
|
||||
|
||||
@RegisteredInstanceCommand('2.5.0', 1798000004000, { type: 'slow' })
|
||||
export class EncryptConnectedAccountTokensSlowInstanceCommand
|
||||
implements SlowInstanceCommand
|
||||
@@ -29,14 +34,11 @@ export class EncryptConnectedAccountTokensSlowInstanceCommand
|
||||
) {}
|
||||
|
||||
async runDataMigration(dataSource: DataSource): Promise<void> {
|
||||
// Cursor + prefix-filter on the SELECT makes the loop both bounded in
|
||||
// memory and idempotent: re-runs after a partial failure skip rows that
|
||||
// were already encrypted on a prior pass.
|
||||
let cursor = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
while (true) {
|
||||
const rows: ConnectedAccountTokenRow[] = await dataSource.query(
|
||||
`SELECT id, "accessToken", "refreshToken"
|
||||
`SELECT id, "workspaceId", "accessToken", "refreshToken"
|
||||
FROM "core"."connectedAccount"
|
||||
WHERE id > $1
|
||||
AND (
|
||||
@@ -45,11 +47,7 @@ export class EncryptConnectedAccountTokensSlowInstanceCommand
|
||||
)
|
||||
ORDER BY id
|
||||
LIMIT $3`,
|
||||
[
|
||||
cursor,
|
||||
`${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}%`,
|
||||
BACKFILL_BATCH_SIZE,
|
||||
],
|
||||
[cursor, V2_ENCRYPTED_LIKE_PATTERN, BACKFILL_BATCH_SIZE],
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
@@ -59,28 +57,23 @@ export class EncryptConnectedAccountTokensSlowInstanceCommand
|
||||
for (const row of rows) {
|
||||
const sets: string[] = [];
|
||||
const params: unknown[] = [row.id];
|
||||
if (
|
||||
row.accessToken !== null &&
|
||||
!row.accessToken.startsWith(CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX)
|
||||
) {
|
||||
|
||||
if (isPlaintext(row.accessToken)) {
|
||||
params.push(
|
||||
this.connectedAccountTokenEncryptionService.encrypt(
|
||||
row.accessToken,
|
||||
),
|
||||
this.connectedAccountTokenEncryptionService.encrypt({
|
||||
plaintext: row.accessToken,
|
||||
workspaceId: row.workspaceId,
|
||||
}),
|
||||
);
|
||||
sets.push(`"accessToken" = $${params.length}`);
|
||||
}
|
||||
|
||||
if (
|
||||
row.refreshToken !== null &&
|
||||
!row.refreshToken.startsWith(
|
||||
CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX,
|
||||
)
|
||||
) {
|
||||
if (isPlaintext(row.refreshToken)) {
|
||||
params.push(
|
||||
this.connectedAccountTokenEncryptionService.encrypt(
|
||||
row.refreshToken,
|
||||
),
|
||||
this.connectedAccountTokenEncryptionService.encrypt({
|
||||
plaintext: row.refreshToken,
|
||||
workspaceId: row.workspaceId,
|
||||
}),
|
||||
);
|
||||
sets.push(`"refreshToken" = $${params.length}`);
|
||||
}
|
||||
@@ -105,20 +98,20 @@ export class EncryptConnectedAccountTokensSlowInstanceCommand
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."connectedAccount"
|
||||
ADD CONSTRAINT "${ACCESS_TOKEN_CHECK_CONSTRAINT_NAME}"
|
||||
CHECK ("accessToken" IS NULL OR "accessToken" LIKE '${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}%')`,
|
||||
CHECK ("accessToken" IS NULL OR "accessToken" LIKE '${V2_ENCRYPTED_LIKE_PATTERN}')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."connectedAccount"
|
||||
ADD CONSTRAINT "${REFRESH_TOKEN_CHECK_CONSTRAINT_NAME}"
|
||||
CHECK ("refreshToken" IS NULL OR "refreshToken" LIKE '${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}%')`,
|
||||
CHECK ("refreshToken" IS NULL OR "refreshToken" LIKE '${V2_ENCRYPTED_LIKE_PATTERN}')`,
|
||||
);
|
||||
}
|
||||
|
||||
// Deliberately do NOT decrypt rows on rollback — re-introducing plaintext
|
||||
// tokens to the database would be a security regression. Dropping the
|
||||
// CHECK constraints is enough; ConnectedAccountTokenEncryptionService can
|
||||
// still read the encrypted columns whether or not the constraints exist.
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Deliberately do NOT decrypt rows on rollback — re-introducing plaintext
|
||||
// tokens to the database would be a security regression. Dropping the
|
||||
// CHECK constraints is enough; ConnectedAccountTokenEncryptionService can
|
||||
// still read the encrypted columns whether or not the constraints exist.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."connectedAccount"
|
||||
DROP CONSTRAINT IF EXISTS "${REFRESH_TOKEN_CHECK_CONSTRAINT_NAME}"`,
|
||||
|
||||
+92
-81
@@ -1,20 +1,22 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type DataSource } from 'typeorm';
|
||||
|
||||
import { EncryptConnectedAccountTokensSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000004000-encrypt-connected-account-tokens';
|
||||
import {
|
||||
CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX,
|
||||
type ConnectedAccountTokenEncryptionService,
|
||||
} from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
|
||||
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
|
||||
import { type ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
|
||||
|
||||
type FakeRow = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
};
|
||||
|
||||
// In-memory stand-in that mimics the slow command's exact SELECT / UPDATE
|
||||
// shape (LIKE filter, cursor, batch) — anything looser would let regressions
|
||||
// in the SQL slip past these tests.
|
||||
const FAKE_V2_KEY_ID = 'deadbeef';
|
||||
|
||||
const wrapAsV2 = (plaintext: string, workspaceId: string): string =>
|
||||
`${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}${FAKE_V2_KEY_ID}:CIPHER(${plaintext}|${workspaceId})`;
|
||||
|
||||
const buildFakeDataSource = (
|
||||
initialRows: FakeRow[],
|
||||
{ batchSize }: { batchSize: number } = { batchSize: 500 },
|
||||
@@ -26,23 +28,29 @@ const buildFakeDataSource = (
|
||||
const rows = [...initialRows].sort((a, b) => a.id.localeCompare(b.id));
|
||||
let queryCallCount = 0;
|
||||
|
||||
const matchesLikePattern = (value: string, pattern: string): boolean => {
|
||||
const escaped = pattern.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&');
|
||||
const expr = escaped.replace(/%/g, '.*').replace(/_/g, '.');
|
||||
|
||||
return new RegExp(`^${expr}$`).test(value);
|
||||
};
|
||||
|
||||
const fakeDataSource = {
|
||||
query: jest.fn(async (sql: string, params?: unknown[]) => {
|
||||
queryCallCount++;
|
||||
|
||||
if (sql.includes('SELECT id')) {
|
||||
const cursor = params?.[0] as string;
|
||||
const prefixPattern = params?.[1] as string;
|
||||
const prefix = prefixPattern.replace(/%$/, '');
|
||||
const likePattern = params?.[1] as string;
|
||||
|
||||
return rows
|
||||
.filter((row) => row.id > cursor)
|
||||
.filter(
|
||||
(row) =>
|
||||
(row.accessToken !== null &&
|
||||
!row.accessToken.startsWith(prefix)) ||
|
||||
(row.refreshToken !== null &&
|
||||
!row.refreshToken.startsWith(prefix)),
|
||||
(isDefined(row.accessToken) &&
|
||||
!matchesLikePattern(row.accessToken, likePattern)) ||
|
||||
(isDefined(row.refreshToken) &&
|
||||
!matchesLikePattern(row.refreshToken, likePattern)),
|
||||
)
|
||||
.slice(0, batchSize);
|
||||
}
|
||||
@@ -51,20 +59,19 @@ const buildFakeDataSource = (
|
||||
const id = params?.[0] as string;
|
||||
const target = rows.find((row) => row.id === id);
|
||||
|
||||
if (!target) {
|
||||
if (!isDefined(target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mirror the SQL: SET "accessToken" = $N, "refreshToken" = $M WHERE id = $1
|
||||
const accessTokenMatch = sql.match(/"accessToken" = \$(\d+)/);
|
||||
const refreshTokenMatch = sql.match(/"refreshToken" = \$(\d+)/);
|
||||
|
||||
if (accessTokenMatch) {
|
||||
if (isDefined(accessTokenMatch)) {
|
||||
target.accessToken = params?.[
|
||||
Number(accessTokenMatch[1]) - 1
|
||||
] as string;
|
||||
}
|
||||
if (refreshTokenMatch) {
|
||||
if (isDefined(refreshTokenMatch)) {
|
||||
target.refreshToken = params?.[
|
||||
Number(refreshTokenMatch[1]) - 1
|
||||
] as string;
|
||||
@@ -84,109 +91,118 @@ const buildFakeDataSource = (
|
||||
};
|
||||
};
|
||||
|
||||
const buildFakeTokenEncryptionService =
|
||||
(): ConnectedAccountTokenEncryptionService =>
|
||||
({
|
||||
encrypt: jest.fn(
|
||||
({
|
||||
plaintext,
|
||||
workspaceId,
|
||||
}: {
|
||||
plaintext: string;
|
||||
workspaceId: string;
|
||||
}): string => wrapAsV2(plaintext, workspaceId),
|
||||
),
|
||||
}) as unknown as ConnectedAccountTokenEncryptionService;
|
||||
|
||||
const buildCommand = (): {
|
||||
command: EncryptConnectedAccountTokensSlowInstanceCommand;
|
||||
} => {
|
||||
const command = new EncryptConnectedAccountTokensSlowInstanceCommand(
|
||||
buildFakeTokenEncryptionService(),
|
||||
);
|
||||
|
||||
return { command };
|
||||
};
|
||||
|
||||
describe('EncryptConnectedAccountTokensSlowInstanceCommand', () => {
|
||||
// Real AES round-trip is asserted in ConnectedAccountTokenEncryptionService's
|
||||
// own spec; here we use a CIPHER(...) wrapper so assertions match exact strings.
|
||||
const buildFakeTokenEncryptionService =
|
||||
(): ConnectedAccountTokenEncryptionService =>
|
||||
({
|
||||
encrypt: jest.fn(
|
||||
(plaintext: string): string =>
|
||||
`${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(${plaintext})`,
|
||||
),
|
||||
}) as unknown as ConnectedAccountTokenEncryptionService;
|
||||
|
||||
const buildCommand = (): {
|
||||
command: EncryptConnectedAccountTokensSlowInstanceCommand;
|
||||
connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService;
|
||||
} => {
|
||||
const connectedAccountTokenEncryptionService =
|
||||
buildFakeTokenEncryptionService();
|
||||
const command = new EncryptConnectedAccountTokensSlowInstanceCommand(
|
||||
connectedAccountTokenEncryptionService,
|
||||
);
|
||||
|
||||
return { command, connectedAccountTokenEncryptionService };
|
||||
};
|
||||
|
||||
describe('runDataMigration', () => {
|
||||
it('should encrypt every legacy plaintext row and leave already-prefixed rows untouched', async () => {
|
||||
const alreadyEncrypted = `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}preexisting-ciphertext`;
|
||||
it('upgrades plaintext rows to v2 with workspaceId threaded through, and leaves v2 rows untouched', async () => {
|
||||
const wsA = '11111111-1111-1111-1111-111111111111';
|
||||
const wsB = '22222222-2222-2222-2222-222222222222';
|
||||
const alreadyV2 = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}cafebabe:preexisting-v2-ciphertext`;
|
||||
|
||||
const { dataSource, rows } = buildFakeDataSource([
|
||||
{
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
workspaceId: wsA,
|
||||
accessToken: 'plaintext-access-1',
|
||||
refreshToken: 'plaintext-refresh-1',
|
||||
},
|
||||
{
|
||||
id: 'bbbbbbbb-0000-0000-0000-000000000002',
|
||||
accessToken: alreadyEncrypted,
|
||||
refreshToken: alreadyEncrypted,
|
||||
workspaceId: wsB,
|
||||
accessToken: alreadyV2,
|
||||
refreshToken: alreadyV2,
|
||||
},
|
||||
{
|
||||
id: 'cccccccc-0000-0000-0000-000000000003',
|
||||
workspaceId: wsB,
|
||||
accessToken: 'plaintext-access-3',
|
||||
refreshToken: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const { command } = buildCommand();
|
||||
|
||||
await command.runDataMigration(dataSource);
|
||||
await buildCommand().command.runDataMigration(dataSource);
|
||||
|
||||
expect(rows()).toEqual([
|
||||
{
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-access-1)`,
|
||||
refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-refresh-1)`,
|
||||
workspaceId: wsA,
|
||||
accessToken: wrapAsV2('plaintext-access-1', wsA),
|
||||
refreshToken: wrapAsV2('plaintext-refresh-1', wsA),
|
||||
},
|
||||
{
|
||||
id: 'bbbbbbbb-0000-0000-0000-000000000002',
|
||||
accessToken: alreadyEncrypted,
|
||||
refreshToken: alreadyEncrypted,
|
||||
workspaceId: wsB,
|
||||
accessToken: alreadyV2,
|
||||
refreshToken: alreadyV2,
|
||||
},
|
||||
{
|
||||
id: 'cccccccc-0000-0000-0000-000000000003',
|
||||
accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-access-3)`,
|
||||
workspaceId: wsB,
|
||||
accessToken: wrapAsV2('plaintext-access-3', wsB),
|
||||
refreshToken: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Regression guard: the SELECT filter is per-row (one column unencrypted is
|
||||
// enough to fetch the row), so the loop body sees rows where one column is
|
||||
// already prefixed and the other isn't. The per-cell prefix check inside
|
||||
// the loop is what prevents the prefixed column from being double-encrypted
|
||||
// into `enc:v1:CIPHER(enc:v1:...)`. If that check ever regresses, this is
|
||||
// the test that should fail.
|
||||
it('should only encrypt the plaintext column when a row mixes encrypted and plaintext tokens', async () => {
|
||||
const alreadyEncryptedAccess = `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}preexisting-access-cipher`;
|
||||
// Regression guard: the SELECT filter is per-row (one non-v2 column is
|
||||
// enough to fetch the row), so the loop sees rows where one column is
|
||||
// already v2 and the other is plaintext. The per-cell guard inside the
|
||||
// loop is what prevents the v2 column from being double-encrypted.
|
||||
it('only rewrites the non-v2 column when a row mixes v2 and plaintext', async () => {
|
||||
const ws = '33333333-3333-3333-3333-333333333333';
|
||||
const alreadyV2 = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}cafebabe:preexisting-v2-access`;
|
||||
|
||||
const { dataSource, rows } = buildFakeDataSource([
|
||||
{
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
accessToken: alreadyEncryptedAccess,
|
||||
workspaceId: ws,
|
||||
accessToken: alreadyV2,
|
||||
refreshToken: 'plaintext-refresh-mixed',
|
||||
},
|
||||
]);
|
||||
|
||||
const { command } = buildCommand();
|
||||
|
||||
await command.runDataMigration(dataSource);
|
||||
await buildCommand().command.runDataMigration(dataSource);
|
||||
|
||||
expect(rows()).toEqual([
|
||||
{
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
accessToken: alreadyEncryptedAccess,
|
||||
refreshToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-refresh-mixed)`,
|
||||
workspaceId: ws,
|
||||
accessToken: alreadyV2,
|
||||
refreshToken: wrapAsV2('plaintext-refresh-mixed', ws),
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should be idempotent — re-running on already-migrated data leaves it unchanged', async () => {
|
||||
|
||||
it('is idempotent — a second run leaves already-migrated data unchanged', async () => {
|
||||
const ws = '44444444-4444-4444-4444-444444444444';
|
||||
|
||||
const { dataSource, rows } = buildFakeDataSource([
|
||||
{
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
workspaceId: ws,
|
||||
accessToken: 'plaintext-token',
|
||||
refreshToken: null,
|
||||
},
|
||||
@@ -197,7 +213,8 @@ describe('EncryptConnectedAccountTokensSlowInstanceCommand', () => {
|
||||
const expectedFinalState = [
|
||||
{
|
||||
id: 'aaaaaaaa-0000-0000-0000-000000000001',
|
||||
accessToken: `${CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX}CIPHER(plaintext-token)`,
|
||||
workspaceId: ws,
|
||||
accessToken: wrapAsV2('plaintext-token', ws),
|
||||
refreshToken: null,
|
||||
},
|
||||
];
|
||||
@@ -209,11 +226,11 @@ describe('EncryptConnectedAccountTokensSlowInstanceCommand', () => {
|
||||
expect(rows()).toEqual(expectedFinalState);
|
||||
});
|
||||
|
||||
it('should paginate through more rows than the batch size', async () => {
|
||||
// 1100 rows + batch size 500 → at least 3 SELECT batches.
|
||||
it('paginates through more rows than the batch size', async () => {
|
||||
const ws = '55555555-5555-5555-5555-555555555555';
|
||||
const initialRows: FakeRow[] = Array.from({ length: 1100 }, (_, idx) => ({
|
||||
// Lex-sortable hex IDs so the cursor advance works the way the SQL does.
|
||||
id: `${idx.toString(16).padStart(12, '0')}-0000-0000-0000-000000000000`,
|
||||
workspaceId: ws,
|
||||
accessToken: `plaintext-${idx}`,
|
||||
refreshToken: null,
|
||||
}));
|
||||
@@ -223,21 +240,15 @@ describe('EncryptConnectedAccountTokensSlowInstanceCommand', () => {
|
||||
{ batchSize: 500 },
|
||||
);
|
||||
|
||||
const { command } = buildCommand();
|
||||
await buildCommand().command.runDataMigration(dataSource);
|
||||
|
||||
await command.runDataMigration(dataSource);
|
||||
|
||||
// Every row got encrypted
|
||||
expect(
|
||||
rows().every((row) =>
|
||||
row.accessToken!.startsWith(
|
||||
CONNECTED_ACCOUNT_TOKEN_ENCRYPTION_PREFIX,
|
||||
),
|
||||
row.accessToken!.startsWith(SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Sanity check: at least the expected number of SELECT batches happened
|
||||
// (3 SELECTs for 500/500/100 + 1 final empty SELECT + 1100 UPDATEs)
|
||||
// 3 SELECT batches (500/500/100) + 1 final empty SELECT + 1100 UPDATEs.
|
||||
expect(queryCallCount()).toBeGreaterThanOrEqual(1100 + 4);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user