-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(secret-v2-bridge): move/delete personal overrides when moving shared secrets #6078
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
PuneetKumar1790
wants to merge
2
commits into
Infisical:main
Choose a base branch
from
PuneetKumar1790:fix/move-personal-overrides-4627
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+379
−1
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
314 changes: 314 additions & 0 deletions
314
backend/src/services/secret-v2-bridge/secret-v2-bridge-move-overrides.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,314 @@ | ||
| /* eslint-disable @typescript-eslint/no-explicit-any */ | ||
| /* eslint-disable @typescript-eslint/no-unsafe-assignment */ | ||
| /* eslint-disable @typescript-eslint/no-unsafe-member-access */ | ||
| /* eslint-disable @typescript-eslint/no-unsafe-argument */ | ||
|
|
||
| import { SecretType, TableName } from "@app/db/schemas"; | ||
|
|
||
| import { secretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; | ||
|
|
||
| /** | ||
| * Tests for personal override DAL helpers used by moveSecrets | ||
| * to keep personal overrides aligned with shared secrets. | ||
| * | ||
| * These tests use a mock DB layer to verify the query logic. | ||
| */ | ||
|
|
||
| // Helper to create a mock knex/db chain | ||
| const createMockDb = () => { | ||
| const chain: Record<string, ReturnType<typeof vi.fn>> = {}; | ||
|
|
||
| chain.where = vi.fn().mockReturnValue(chain); | ||
| chain.whereIn = vi.fn().mockReturnValue(chain); | ||
| chain.update = vi.fn().mockReturnValue(chain); | ||
| chain.delete = vi.fn().mockReturnValue(chain); | ||
| chain.returning = vi.fn().mockResolvedValue([]); | ||
|
|
||
| const db = vi.fn().mockReturnValue(chain); | ||
| // replicaNode returns the same callable | ||
| (db as any).replicaNode = vi.fn().mockReturnValue(db); | ||
| // ref / raw helpers used by other DAL methods | ||
| (db as any).ref = vi.fn().mockReturnValue({ withSchema: vi.fn().mockReturnValue({ as: vi.fn() }) }); | ||
| (db as any).raw = vi.fn(); | ||
|
|
||
| return { db, chain }; | ||
| }; | ||
|
|
||
| describe("movePersonalOverrides", () => { | ||
| test("should call db with correct filter for personal secrets by key and folderId", async () => { | ||
| const { db, chain } = createMockDb(); | ||
|
|
||
| const movedOverrides = [ | ||
| { id: "override-1", key: "API_URL", type: SecretType.Personal, folderId: "dest-folder", userId: "user-1" }, | ||
| { id: "override-2", key: "API_URL", type: SecretType.Personal, folderId: "dest-folder", userId: "user-2" } | ||
| ]; | ||
| chain.returning.mockResolvedValue(movedOverrides); | ||
|
|
||
| // Create a minimal DAL with just the method we need | ||
| const mockKeyStore = { | ||
| pgIncrementBy: vi.fn(), | ||
| deleteItem: vi.fn() | ||
| } as any; | ||
|
|
||
| const dal = secretV2BridgeDALFactory({ db: db as any, keyStore: mockKeyStore }); | ||
|
|
||
| const result = await dal.movePersonalOverrides("source-folder", "dest-folder", ["API_URL"]); | ||
|
|
||
| // Verify correct table was targeted | ||
| expect(db).toHaveBeenCalledWith(TableName.SecretV2); | ||
|
|
||
| // Verify where clause filters by source folderId and Personal type | ||
| expect(chain.where).toHaveBeenCalledWith({ | ||
| folderId: "source-folder", | ||
| type: SecretType.Personal | ||
| }); | ||
|
|
||
| // Verify whereIn filters by the secret keys | ||
| expect(chain.whereIn).toHaveBeenCalledWith("key", ["API_URL"]); | ||
|
|
||
| // Verify update sets correct destination folderId | ||
| expect(chain.update).toHaveBeenCalledWith({ folderId: "dest-folder" }); | ||
|
|
||
| // Verify returning all columns | ||
| expect(chain.returning).toHaveBeenCalledWith("*"); | ||
|
|
||
| // Verify result | ||
| expect(result).toEqual(movedOverrides); | ||
| expect(result).toHaveLength(2); | ||
| }); | ||
|
|
||
| test("should return empty array and skip DB call when secretKeys is empty", async () => { | ||
| const { db } = createMockDb(); | ||
| const mockKeyStore = { | ||
| pgIncrementBy: vi.fn(), | ||
| deleteItem: vi.fn() | ||
| } as any; | ||
|
|
||
| const dal = secretV2BridgeDALFactory({ db: db as any, keyStore: mockKeyStore }); | ||
|
|
||
| const result = await dal.movePersonalOverrides("source-folder", "dest-folder", []); | ||
|
|
||
| // Should not call db at all | ||
| expect(db).not.toHaveBeenCalledWith(TableName.SecretV2); | ||
| expect(result).toEqual([]); | ||
| }); | ||
|
|
||
| test("should handle multiple secret keys in bulk move", async () => { | ||
| const { db, chain } = createMockDb(); | ||
|
|
||
| const movedOverrides = [ | ||
| { id: "override-1", key: "API_URL", type: SecretType.Personal, folderId: "dest-folder", userId: "user-1" }, | ||
| { id: "override-2", key: "DB_HOST", type: SecretType.Personal, folderId: "dest-folder", userId: "user-1" }, | ||
| { id: "override-3", key: "API_URL", type: SecretType.Personal, folderId: "dest-folder", userId: "user-2" } | ||
| ]; | ||
| chain.returning.mockResolvedValue(movedOverrides); | ||
|
|
||
| const mockKeyStore = { | ||
| pgIncrementBy: vi.fn(), | ||
| deleteItem: vi.fn() | ||
| } as any; | ||
|
|
||
| const dal = secretV2BridgeDALFactory({ db: db as any, keyStore: mockKeyStore }); | ||
|
|
||
| const result = await dal.movePersonalOverrides("source-folder", "dest-folder", ["API_URL", "DB_HOST"]); | ||
|
|
||
| // Verify whereIn with multiple keys | ||
| expect(chain.whereIn).toHaveBeenCalledWith("key", ["API_URL", "DB_HOST"]); | ||
| expect(result).toHaveLength(3); | ||
| }); | ||
|
|
||
| test("should use provided transaction when given", async () => { | ||
| const { chain } = createMockDb(); | ||
|
|
||
| // Create a separate tx mock | ||
| const txChain: Record<string, ReturnType<typeof vi.fn>> = {}; | ||
| txChain.where = vi.fn().mockReturnValue(txChain); | ||
| txChain.whereIn = vi.fn().mockReturnValue(txChain); | ||
| txChain.update = vi.fn().mockReturnValue(txChain); | ||
| txChain.delete = vi.fn().mockReturnValue(txChain); | ||
| txChain.returning = vi.fn().mockResolvedValue([]); | ||
|
|
||
| const tx = vi.fn().mockReturnValue(txChain); | ||
|
|
||
| // Create db that should NOT be called | ||
| const db = vi.fn().mockReturnValue(chain); | ||
| (db as any).replicaNode = vi.fn().mockReturnValue(db); | ||
| (db as any).ref = vi.fn().mockReturnValue({ withSchema: vi.fn().mockReturnValue({ as: vi.fn() }) }); | ||
| (db as any).raw = vi.fn(); | ||
|
|
||
| const mockKeyStore = { | ||
| pgIncrementBy: vi.fn(), | ||
| deleteItem: vi.fn() | ||
| } as any; | ||
|
|
||
| const dal = secretV2BridgeDALFactory({ db: db as any, keyStore: mockKeyStore }); | ||
|
|
||
| await dal.movePersonalOverrides("source-folder", "dest-folder", ["API_URL"], tx as any); | ||
|
|
||
| // tx should be used instead of db | ||
| expect(tx).toHaveBeenCalledWith(TableName.SecretV2); | ||
| // db should NOT have been called for this query | ||
| expect(db).not.toHaveBeenCalledWith(TableName.SecretV2); | ||
| }); | ||
|
|
||
| test("should delete conflicting destination overrides before moving source overrides", async () => { | ||
| const { db, chain } = createMockDb(); | ||
|
|
||
| const movedOverrides = [ | ||
| { id: "source-override-1", key: "API_URL", type: SecretType.Personal, folderId: "dest-folder", userId: "user-1" } | ||
| ]; | ||
| chain.returning.mockResolvedValue(movedOverrides); | ||
| chain.delete.mockReturnValue(chain); | ||
|
|
||
| const mockKeyStore = { | ||
| pgIncrementBy: vi.fn(), | ||
| deleteItem: vi.fn() | ||
| } as any; | ||
|
|
||
| const dal = secretV2BridgeDALFactory({ db: db as any, keyStore: mockKeyStore }); | ||
|
|
||
| const result = await dal.movePersonalOverrides("source-folder", "dest-folder", ["API_URL"]); | ||
|
|
||
| // Verify delete was called on destination first | ||
| const { calls } = (chain.where as any).mock; | ||
| // First call: delete destination overrides | ||
| expect(calls[0][0]).toEqual({ | ||
| folderId: "dest-folder", | ||
| type: SecretType.Personal | ||
| }); | ||
| // Second call: move source overrides | ||
| expect(calls[1][0]).toEqual({ | ||
| folderId: "source-folder", | ||
| type: SecretType.Personal | ||
| }); | ||
|
|
||
| // Verify delete was called | ||
| expect(chain.delete).toHaveBeenCalled(); | ||
| // Verify update was called | ||
| expect(chain.update).toHaveBeenCalledWith({ folderId: "dest-folder" }); | ||
| // Verify result is the moved overrides | ||
| expect(result).toEqual(movedOverrides); | ||
| }); | ||
|
|
||
| test("should return empty array when no personal overrides exist for given keys", async () => { | ||
| const { db, chain } = createMockDb(); | ||
| chain.returning.mockResolvedValue([]); | ||
|
|
||
| const mockKeyStore = { | ||
| pgIncrementBy: vi.fn(), | ||
| deleteItem: vi.fn() | ||
| } as any; | ||
|
|
||
| const dal = secretV2BridgeDALFactory({ db: db as any, keyStore: mockKeyStore }); | ||
|
|
||
| const result = await dal.movePersonalOverrides("source-folder", "dest-folder", ["NON_EXISTENT_KEY"]); | ||
|
|
||
| expect(result).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("deletePersonalOverridesByKeys", () => { | ||
| test("should delete personal overrides in source folder by keys", async () => { | ||
| const { db, chain } = createMockDb(); | ||
|
|
||
| const deletedOverrides = [ | ||
| { id: "override-1", key: "API_URL", type: SecretType.Personal, folderId: "source-folder" } | ||
| ]; | ||
| chain.returning.mockResolvedValue(deletedOverrides); | ||
|
|
||
| const mockKeyStore = { | ||
| pgIncrementBy: vi.fn(), | ||
| deleteItem: vi.fn() | ||
| } as any; | ||
|
|
||
| const dal = secretV2BridgeDALFactory({ db: db as any, keyStore: mockKeyStore }); | ||
|
|
||
| const result = await dal.deletePersonalOverridesByKeys("source-folder", ["API_URL"]); | ||
|
|
||
| expect(db).toHaveBeenCalledWith(TableName.SecretV2); | ||
| expect(chain.where).toHaveBeenCalledWith({ | ||
| folderId: "source-folder", | ||
| type: SecretType.Personal | ||
| }); | ||
| expect(chain.whereIn).toHaveBeenCalledWith("key", ["API_URL"]); | ||
| expect(chain.delete).toHaveBeenCalled(); | ||
| expect(chain.returning).toHaveBeenCalledWith("*"); | ||
| expect(result).toEqual(deletedOverrides); | ||
| }); | ||
|
|
||
| test("should return empty array and skip DB call when secretKeys is empty", async () => { | ||
| const { db } = createMockDb(); | ||
|
|
||
| const mockKeyStore = { | ||
| pgIncrementBy: vi.fn(), | ||
| deleteItem: vi.fn() | ||
| } as any; | ||
|
|
||
| const dal = secretV2BridgeDALFactory({ db: db as any, keyStore: mockKeyStore }); | ||
|
|
||
| const result = await dal.deletePersonalOverridesByKeys("source-folder", []); | ||
|
|
||
| expect(db).not.toHaveBeenCalledWith(TableName.SecretV2); | ||
| expect(result).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("moveSecrets personal override handling - integration logic", () => { | ||
| /** | ||
| * These tests verify the logical flow of what moveSecrets should do | ||
| * with personal overrides in different scenarios. | ||
| */ | ||
|
|
||
| test("scenario: direct move (no approval policies) should move overrides", () => { | ||
| // This test documents the expected behavior: | ||
| // When isDestinationUpdated = true AND isSourceUpdated = true | ||
| // (no approval policies on either side), personal overrides should be moved. | ||
|
|
||
| const isDestinationUpdated = true; | ||
| const isSourceUpdated = true; | ||
|
|
||
| // In the fixed code, movePersonalOverrides is called when: | ||
| // - Source has no approval policy (direct delete path) | ||
| // - AND destination was directly updated | ||
| const shouldMoveOverrides = isDestinationUpdated && isSourceUpdated; | ||
| expect(shouldMoveOverrides).toBe(true); | ||
| }); | ||
|
|
||
| test("scenario: destination has approval policy should delete source overrides", () => { | ||
| // When destination has approval policy, isDestinationUpdated = false. | ||
| // Source shared secrets can still be deleted directly, so source overrides | ||
| // must be deleted to prevent orphaned personal records. | ||
|
|
||
| const isDestinationUpdated = false; // approval policy at destination | ||
| const isSourceUpdated = true; | ||
|
|
||
| const shouldMoveOverrides = isDestinationUpdated; | ||
| const shouldDeleteOverrides = !isDestinationUpdated && isSourceUpdated; | ||
|
|
||
| expect(shouldMoveOverrides).toBe(false); | ||
| expect(shouldDeleteOverrides).toBe(true); | ||
| }); | ||
|
|
||
| test("scenario: source has approval policy should NOT move overrides", () => { | ||
| // When source has approval policy, the code enters the approval branch | ||
| // and never reaches the direct-delete + move-overrides code. | ||
|
|
||
| const isSourceUpdated = false; // approval policy at source | ||
|
|
||
| // The movePersonalOverrides call is inside the `else` branch | ||
| // of the source approval policy check, so it's never reached. | ||
| const sourceHasApprovalPolicy = !isSourceUpdated; | ||
| expect(sourceHasApprovalPolicy).toBe(true); | ||
| // In this case, no overrides are moved (correct behavior: | ||
| // shared secret still exists at source pending approval) | ||
| }); | ||
|
|
||
| test("scenario: both have approval policies should NOT move overrides", () => { | ||
| const isDestinationUpdated = false; // approval policy at destination | ||
| const isSourceUpdated = false; // approval policy at source | ||
|
|
||
| // Neither direct path is taken, no overrides moved | ||
| const shouldMoveOverrides = isDestinationUpdated && isSourceUpdated; | ||
| expect(shouldMoveOverrides).toBe(false); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
moveSecretsoverwrites into a destination that already has personal overrides for the same key/user, this bulkupdate({ folderId: destinationFolderId })blindly relocates source overrides and can create duplicate personal rows in the destination folder. That violates the one-override-per-user/key assumption used by secret resolution (the priority map only keys bykey-folderand will pick one personal row based on query order), so users can see stale or nondeterministic override values after a move. Please resolve destination conflicts (e.g., upsert or delete/replace perkey + userId) before updating folder IDs.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for catching this! I've addressed the conflict scenario in the latest commits.
What I added:
Changes:
movePersonalOverridesnow deletes conflicting destination overrides before moving source onesAll tests pass and lint is clean. The fix ensures source overrides safely replace any pre-existing destination overrides without creating duplicates.