Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/secret-state-update-time-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tailor-platform/sdk": patch
---

Fix deploy silently skipping a secret update after the remote value changed outside the current checkout (a deploy from another machine, or a console-side edit). The local hash state now also records the platform's update timestamp for each secret and distrusts the stored hash when the remote timestamp no longer matches. After upgrading, the first deploy re-pushes managed secrets once to seed the new state format.
231 changes: 196 additions & 35 deletions packages/sdk/src/cli/commands/deploy/secret-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,14 @@ function createMockApplyClient() {
} as unknown as OperatorClient;
}

function createMockPlanClient(existingSecrets: string[] = [], vaultName = "my-vault") {
type ExistingSecretFixture =
| string
| { name: string; updateTime?: { seconds: bigint; nanos: number } };

function createMockPlanClient(
existingSecrets: ExistingSecretFixture[] = [],
vaultName = "my-vault",
) {
return {
listSecretManagerVaults: vi.fn().mockResolvedValue({
vaults: [{ name: vaultName }],
Expand All @@ -75,7 +82,9 @@ function createMockPlanClient(existingSecrets: string[] = [], vaultName = "my-va
metadata: { labels: { "sdk-name": "test-app", "sdk-version": sdkVersion } },
}),
listSecretManagerSecrets: vi.fn().mockResolvedValue({
secrets: existingSecrets.map((name) => ({ name })),
secrets: existingSecrets.map((secret) =>
typeof secret === "string" ? { name: secret } : secret,
),
nextPageToken: "",
}),
} as unknown as OperatorClient;
Expand Down Expand Up @@ -160,7 +169,7 @@ describe("applySecretManager phase separation", () => {
mockLoadSecretsState.mockReturnValue({
vaults: {
"my-vault": {
"orphan-secret": hashValue("orphan-value"),
"orphan-secret": { hash: hashValue("orphan-value") },
},
},
});
Expand Down Expand Up @@ -253,28 +262,17 @@ describe("planSecretManager hash-based diff", () => {
mockLoadSecretsState.mockReturnValue({ vaults: {} });
});

test("skips update when hash matches stored state", async () => {
test("includes update when forceApplyAll is enabled even if evidence matches", async () => {
const secretValue = "my-secret-value";
mockLoadSecretsState.mockReturnValue({
vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } },
vaults: {
"my-vault": { "existing-secret": { hash: hashValue(secretValue), updateTime: "100.5" } },
},
});

const client = createMockPlanClient(["existing-secret"]);
const ctx = createPlanContext(client, [
{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] },
const client = createMockPlanClient([
{ name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } },
]);

const result = await planSecretManager(ctx);
expect(result.secretChangeSet.updates).toHaveLength(0);
});

test("includes update when forceApplyAll is enabled even if hash matches", async () => {
const secretValue = "my-secret-value";
mockLoadSecretsState.mockReturnValue({
vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } },
});

const client = createMockPlanClient(["existing-secret"]);
const ctx = {
...createPlanContext(client, [
{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] },
Expand All @@ -288,10 +286,14 @@ describe("planSecretManager hash-based diff", () => {

test("includes update when hash does not match", async () => {
mockLoadSecretsState.mockReturnValue({
vaults: { "my-vault": { "existing-secret": hashValue("old-value") } },
vaults: {
"my-vault": { "existing-secret": { hash: hashValue("old-value"), updateTime: "100.5" } },
},
});

const client = createMockPlanClient(["existing-secret"]);
const client = createMockPlanClient([
{ name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } },
]);
const ctx = createPlanContext(client, [
{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: "new-value" }] },
]);
Expand All @@ -317,10 +319,18 @@ describe("planSecretManager hash-based diff", () => {
const secretValue = "my-secret-value";
mockLoadSecretsState.mockImplementation((scope?: { workspaceId: string }) =>
scope?.workspaceId === "ws-a"
? { vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } } }
? {
vaults: {
"my-vault": {
"existing-secret": { hash: hashValue(secretValue), updateTime: "100.5" },
},
},
}
: { vaults: {} },
);
const client = createMockPlanClient(["existing-secret"]);
const client = createMockPlanClient([
{ name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } },
]);
const ctxA = createPlanContext(
client,
[{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }],
Expand Down Expand Up @@ -348,10 +358,18 @@ describe("planSecretManager hash-based diff", () => {
const secretValue = "my-secret-value";
mockLoadSecretsState.mockImplementation((scope?: { applicationId: string }) =>
scope?.applicationId === "app-a"
? { vaults: { "my-vault": { "existing-secret": hashValue(secretValue) } } }
? {
vaults: {
"my-vault": {
"existing-secret": { hash: hashValue(secretValue), updateTime: "100.5" },
},
},
}
: { vaults: {} },
);
const client = createMockPlanClient(["existing-secret"]);
const client = createMockPlanClient([
{ name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } },
]);
const ctxA = createPlanContext(
client,
[{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] }],
Expand All @@ -376,6 +394,145 @@ describe("planSecretManager hash-based diff", () => {
});
});

describe("planSecretManager update-time evidence", () => {
const secretValue = "my-secret-value";
const storedEntry = { hash: hashValue(secretValue), updateTime: "100.5" };

beforeEach(() => {
vi.clearAllMocks();
});

test("skips update when hash and remote updateTime both match stored evidence", async () => {
mockLoadSecretsState.mockReturnValue({
vaults: { "my-vault": { "existing-secret": storedEntry } },
});
const client = createMockPlanClient([
{ name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } },
]);
const ctx = createPlanContext(client, [
{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] },
]);

const result = await planSecretManager(ctx);
expect(result.secretChangeSet.updates).toHaveLength(0);
});

test("updates when the remote updateTime differs from stored evidence", async () => {
mockLoadSecretsState.mockReturnValue({
vaults: { "my-vault": { "existing-secret": storedEntry } },
});
const client = createMockPlanClient([
{ name: "existing-secret", updateTime: { seconds: 200n, nanos: 0 } },
]);
const ctx = createPlanContext(client, [
{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] },
]);

const result = await planSecretManager(ctx);
expect(result.secretChangeSet.updates).toHaveLength(1);
});

test("updates when the stored entry has no updateTime evidence", async () => {
mockLoadSecretsState.mockReturnValue({
vaults: { "my-vault": { "existing-secret": { hash: hashValue(secretValue) } } },
});
const client = createMockPlanClient([
{ name: "existing-secret", updateTime: { seconds: 100n, nanos: 5 } },
]);
const ctx = createPlanContext(client, [
{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] },
]);

const result = await planSecretManager(ctx);
expect(result.secretChangeSet.updates).toHaveLength(1);
});

test("updates when the remote secret carries no updateTime", async () => {
mockLoadSecretsState.mockReturnValue({
vaults: { "my-vault": { "existing-secret": storedEntry } },
});
const client = createMockPlanClient(["existing-secret"]);
const ctx = createPlanContext(client, [
{ vaultName: "my-vault", secrets: [{ name: "existing-secret", value: secretValue }] },
]);

const result = await planSecretManager(ctx);
expect(result.secretChangeSet.updates).toHaveLength(1);
});
});

describe("applySecretManager update-time evidence persistence", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLoadSecretsState.mockReturnValue({ vaults: {} });
});

function evidencePlanResult() {
return {
stateScope,
vaultChangeSet: { creates: [], updates: [], deletes: [], replaces: [] },
secretChangeSet: {
creates: [
{
name: "my-vault/secret-a",
secretName: "secret-a",
workspaceId: "ws-1",
vaultName: "my-vault",
value: "value-a",
},
],
updates: [
{
name: "my-vault/secret-b",
secretName: "secret-b",
workspaceId: "ws-1",
vaultName: "my-vault",
value: "value-b",
},
],
deletes: [],
replaces: [],
},
} as unknown as Awaited<ReturnType<typeof planSecretManager>>;
}

test("stores updateTime evidence from create and update responses", async () => {
const client = {
...createMockApplyClient(),
createSecretManagerSecret: vi.fn().mockResolvedValue({
secret: { name: "secret-a", updateTime: { seconds: 10n, nanos: 1 } },
}),
updateSecretManagerSecret: vi.fn().mockResolvedValue({
secret: { name: "secret-b", updateTime: { seconds: 20n, nanos: 2 } },
}),
} as unknown as OperatorClient;
const application = { secrets: [] } as unknown as Application;

await applySecretManager(client, evidencePlanResult(), "create-update", application);

const savedState = mockSaveSecretsState.mock.calls[0]![1];
expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({
hash: hashValue("value-a"),
updateTime: "10.1",
});
expect(savedState.vaults["my-vault"]["secret-b"]).toEqual({
hash: hashValue("value-b"),
updateTime: "20.2",
});
});

test("stores hash without evidence when a create response is empty", async () => {
const client = createMockApplyClient();
const application = { secrets: [] } as unknown as Application;

await applySecretManager(client, evidencePlanResult(), "create-update", application);

const savedState = mockSaveSecretsState.mock.calls[0]![1];
expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ hash: hashValue("value-a") });
expect(savedState.vaults["my-vault"]["secret-b"]).toEqual({ hash: hashValue("value-b") });
});
});

describe("planSecretManager vault metadata and deletion", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -668,8 +825,8 @@ describe("applySecretManager state persistence", () => {

expect(mockSaveSecretsState).toHaveBeenCalledTimes(1);
const savedState = mockSaveSecretsState.mock.calls[0]![1];
expect(savedState.vaults["my-vault"]["secret-a"]).toBe(hashValue("value-a"));
expect(savedState.vaults["my-vault"]["secret-b"]).toBe(hashValue("value-b"));
expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ hash: hashValue("value-a") });
expect(savedState.vaults["my-vault"]["secret-b"]).toEqual({ hash: hashValue("value-b") });
});

test("does not save state when application is not provided", async () => {
Expand Down Expand Up @@ -706,8 +863,8 @@ describe("applySecretManager state persistence", () => {
mockLoadSecretsState.mockReturnValue({
vaults: {
"my-vault": {
"secret-a": hashValue("value-a"),
orphan: hashValue("orphan-value"),
"secret-a": { hash: hashValue("value-a") },
orphan: { hash: hashValue("orphan-value") },
},
},
});
Expand Down Expand Up @@ -735,15 +892,15 @@ describe("applySecretManager state persistence", () => {

expect(mockSaveSecretsState).toHaveBeenCalledTimes(1);
const savedState = mockSaveSecretsState.mock.calls[0]![1];
expect(savedState.vaults["my-vault"]["secret-a"]).toBe(hashValue("value-a"));
expect(savedState.vaults["my-vault"]["secret-a"]).toEqual({ hash: hashValue("value-a") });
expect(savedState.vaults["my-vault"]["orphan"]).toBeUndefined();
});

test("removes empty vault from state when all secrets deleted", async () => {
mockLoadSecretsState.mockReturnValue({
vaults: {
"my-vault": {
"only-secret": hashValue("value"),
"only-secret": { hash: hashValue("value") },
},
},
});
Expand Down Expand Up @@ -849,7 +1006,7 @@ describe("applySecretManager ignoreNullishValues state persistence", () => {
mockLoadSecretsState.mockReturnValue({
vaults: {
"my-vault": {
"nullish-secret": hashValue("previous-value"),
"nullish-secret": { hash: hashValue("previous-value") },
},
},
});
Expand Down Expand Up @@ -892,7 +1049,11 @@ describe("applySecretManager ignoreNullishValues state persistence", () => {

expect(mockSaveSecretsState).toHaveBeenCalledTimes(1);
const savedState = mockSaveSecretsState.mock.calls[0]![1];
expect(savedState.vaults["my-vault"]["nullish-secret"]).toBe(hashValue("previous-value"));
expect(savedState.vaults["my-vault"]["valued-secret"]).toBe(hashValue("real-value"));
expect(savedState.vaults["my-vault"]["nullish-secret"]).toEqual({
hash: hashValue("previous-value"),
});
expect(savedState.vaults["my-vault"]["valued-secret"]).toEqual({
hash: hashValue("real-value"),
});
});
});
Loading
Loading