mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-19 19:44:29 +00:00
- Introduced support for project and environment assignments for vault providers, allowing for more granular access control. - Updated the vault provider schema to include assignments and modified related components to handle these changes. - Enhanced UI to display assignment status and allow users to manage project and environment associations effectively. - Added validation to ensure assignments reference valid projects and environments within the organization. - Updated tests to cover new functionality related to vault provider assignments and environment variable resolution.
443 lines
13 KiB
TypeScript
443 lines
13 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const findMany = vi.fn();
|
|
|
|
vi.mock("@dokploy/server/db", () => ({
|
|
db: {
|
|
query: {
|
|
vaultProvider: {
|
|
findMany: (...args: unknown[]) => findMany(...args),
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
|
|
import {
|
|
resolveVaultReferences,
|
|
withResolvedVaultRefs,
|
|
} from "@dokploy/server/utils/vault";
|
|
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
|
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
|
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
|
|
|
const mockFetch = vi.fn();
|
|
global.fetch = mockFetch as typeof fetch;
|
|
|
|
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
|
({
|
|
ok,
|
|
status,
|
|
json: async () => body,
|
|
}) as Response;
|
|
|
|
beforeEach(() => {
|
|
findMany.mockReset();
|
|
mockFetch.mockReset();
|
|
});
|
|
|
|
const scope = {
|
|
organizationId: "org-1",
|
|
projectId: "proj-1",
|
|
environmentId: "env-1",
|
|
};
|
|
|
|
const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }];
|
|
|
|
describe("resolveVaultReferences", () => {
|
|
it("returns input untouched and skips the db when no refs are present", async () => {
|
|
const env = "FOO=bar\nBAZ=${{project.QUX}}";
|
|
const result = await resolveVaultReferences(env, scope);
|
|
expect(result).toBe(env);
|
|
expect(findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns null/empty inputs unchanged", async () => {
|
|
expect(await resolveVaultReferences(null, scope)).toBeNull();
|
|
expect(await resolveVaultReferences("", scope)).toBe("");
|
|
});
|
|
|
|
it("throws when refs exist but no organization context is given", async () => {
|
|
await expect(
|
|
resolveVaultReferences("FOO=${{vault.prod.SECRET}}"),
|
|
).rejects.toThrow("not supported in this context");
|
|
});
|
|
|
|
it("throws for an unknown provider", async () => {
|
|
findMany.mockResolvedValue([]);
|
|
await expect(
|
|
resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope),
|
|
).rejects.toThrow('Vault provider "missing" not found');
|
|
});
|
|
|
|
it("throws when the provider is not assigned to the project", async () => {
|
|
findMany.mockResolvedValue([
|
|
{
|
|
name: "prod",
|
|
providerType: "doppler",
|
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
|
assignments: [{ projectId: "other-project", environmentIds: [] }],
|
|
},
|
|
]);
|
|
await expect(
|
|
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
|
|
).rejects.toThrow("not enabled for this project/environment");
|
|
});
|
|
|
|
it("throws when the provider is restricted to another environment", async () => {
|
|
findMany.mockResolvedValue([
|
|
{
|
|
name: "prod",
|
|
providerType: "doppler",
|
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
|
assignments: [
|
|
{ projectId: "proj-1", environmentIds: ["production-env"] },
|
|
],
|
|
},
|
|
]);
|
|
await expect(
|
|
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
|
|
).rejects.toThrow("not enabled for this project/environment");
|
|
});
|
|
|
|
it("allows a provider restricted to the matching environment", async () => {
|
|
findMany.mockResolvedValue([
|
|
{
|
|
name: "prod",
|
|
providerType: "doppler",
|
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
|
assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }],
|
|
},
|
|
]);
|
|
mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" }));
|
|
const result = await resolveVaultReferences(
|
|
"FOO=${{vault.prod.SECRET}}",
|
|
scope,
|
|
);
|
|
expect(result).toBe("FOO=value-1");
|
|
});
|
|
|
|
it("resolves refs through a doppler provider", async () => {
|
|
findMany.mockResolvedValue([
|
|
{
|
|
name: "doppler-prod",
|
|
providerType: "doppler",
|
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
|
assignments: assignedEverywhere,
|
|
},
|
|
]);
|
|
mockFetch.mockResolvedValue(
|
|
jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }),
|
|
);
|
|
|
|
const result = await resolveVaultReferences(
|
|
"DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}",
|
|
scope,
|
|
);
|
|
|
|
expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123");
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("throws when the secret is missing in the provider", async () => {
|
|
findMany.mockResolvedValue([
|
|
{
|
|
name: "doppler-prod",
|
|
providerType: "doppler",
|
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
|
assignments: assignedEverywhere,
|
|
},
|
|
]);
|
|
mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" }));
|
|
|
|
await expect(
|
|
resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope),
|
|
).rejects.toThrow('secret "MISSING" not found');
|
|
});
|
|
});
|
|
|
|
describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => {
|
|
it("resolves entity env sources so sync interpolation sees real values", async () => {
|
|
findMany.mockResolvedValue([
|
|
{
|
|
name: "prod",
|
|
providerType: "doppler",
|
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
|
assignments: assignedEverywhere,
|
|
},
|
|
]);
|
|
mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" }));
|
|
|
|
const entity = {
|
|
env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db",
|
|
environment: {
|
|
environmentId: "env-1",
|
|
env: null,
|
|
project: {
|
|
projectId: "proj-1",
|
|
env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}",
|
|
organizationId: "org-1",
|
|
},
|
|
},
|
|
};
|
|
|
|
const resolved = await withResolvedVaultRefs(entity);
|
|
const prepared = prepareEnvironmentVariables(
|
|
resolved.env,
|
|
resolved.environment.project.env,
|
|
resolved.environment.env,
|
|
);
|
|
|
|
expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]);
|
|
});
|
|
|
|
it("resolves buildArgs and buildSecrets when present", async () => {
|
|
findMany.mockResolvedValue([
|
|
{
|
|
name: "prod",
|
|
providerType: "doppler",
|
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
|
assignments: assignedEverywhere,
|
|
},
|
|
]);
|
|
mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" }));
|
|
|
|
const resolved = await withResolvedVaultRefs({
|
|
env: "FOO=bar",
|
|
buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}",
|
|
buildSecrets: null,
|
|
environment: {
|
|
environmentId: "env-1",
|
|
env: null,
|
|
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
|
|
},
|
|
});
|
|
|
|
expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123");
|
|
expect(resolved.buildSecrets).toBeNull();
|
|
expect(resolved.env).toBe("FOO=bar");
|
|
});
|
|
|
|
it.each([
|
|
["service env", { env: "SECRET=${{vault.locked.X}}" }],
|
|
["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }],
|
|
["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }],
|
|
["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }],
|
|
])(
|
|
"rejects a ref to an unassigned provider placed in the %s",
|
|
async (_source, overrides) => {
|
|
findMany.mockResolvedValue([
|
|
{
|
|
name: "locked",
|
|
providerType: "doppler",
|
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
|
assignments: [{ projectId: "other-project", environmentIds: [] }],
|
|
},
|
|
]);
|
|
|
|
const entity = {
|
|
env: (overrides as { env?: string }).env ?? "FOO=bar",
|
|
buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null,
|
|
environment: {
|
|
environmentId: "env-1",
|
|
env:
|
|
(overrides as { environmentEnv?: string }).environmentEnv ?? null,
|
|
project: {
|
|
projectId: "proj-1",
|
|
env: (overrides as { projectEnv?: string }).projectEnv ?? null,
|
|
organizationId: "org-1",
|
|
},
|
|
},
|
|
};
|
|
|
|
await expect(withResolvedVaultRefs(entity)).rejects.toThrow(
|
|
"not enabled for this project/environment",
|
|
);
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("rejects a ref restricted to another environment from the environment env", async () => {
|
|
findMany.mockResolvedValue([
|
|
{
|
|
name: "prod-only",
|
|
providerType: "doppler",
|
|
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
|
assignments: [
|
|
{ projectId: "proj-1", environmentIds: ["production-env"] },
|
|
],
|
|
},
|
|
]);
|
|
|
|
await expect(
|
|
withResolvedVaultRefs({
|
|
env: null,
|
|
environment: {
|
|
environmentId: "dev-env",
|
|
env: "SECRET=${{vault.prod-only.X}}",
|
|
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
|
|
},
|
|
}),
|
|
).rejects.toThrow("not enabled for this project/environment");
|
|
expect(mockFetch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prepareEnvironmentVariables throws on unresolved vault refs", () => {
|
|
expect(() =>
|
|
prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""),
|
|
).toThrow("Unresolved vault reference");
|
|
});
|
|
|
|
it("keeps working without vault refs", () => {
|
|
const prepared = prepareEnvironmentVariables(
|
|
"FOO=${{project.BAR}}",
|
|
"BAR=baz",
|
|
);
|
|
expect(prepared).toEqual(["FOO=baz"]);
|
|
expect(findMany).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("hashicorp client", () => {
|
|
const config = {
|
|
providerType: "hashicorp" as const,
|
|
url: "https://vault.example.com",
|
|
token: "hvs.token",
|
|
mount: "secret",
|
|
};
|
|
|
|
it("rejects refs without a field", async () => {
|
|
await expect(
|
|
hashicorpClient.getSecrets(config, ["myapp/prod"]),
|
|
).rejects.toThrow("expected format <path>:<field>");
|
|
});
|
|
|
|
it("groups refs by path and picks fields from KV v2 data", async () => {
|
|
mockFetch.mockResolvedValue(
|
|
jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }),
|
|
);
|
|
|
|
const result = await hashicorpClient.getSecrets(config, [
|
|
"myapp/prod:USER",
|
|
"myapp/prod:PASS",
|
|
]);
|
|
|
|
expect(result).toEqual({
|
|
"myapp/prod:USER": "admin",
|
|
"myapp/prod:PASS": "pw",
|
|
});
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
|
"https://vault.example.com/v1/secret/data/myapp/prod",
|
|
);
|
|
});
|
|
|
|
it("throws when a field is missing", async () => {
|
|
mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } }));
|
|
await expect(
|
|
hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]),
|
|
).rejects.toThrow('field "MISSING" not found');
|
|
});
|
|
|
|
it("lists full path:field refs by walking directories", async () => {
|
|
mockFetch.mockImplementation(async (url: string) => {
|
|
if (url.includes("/metadata?list=true")) {
|
|
return jsonResponse({ data: { keys: ["myapp/", "shared"] } });
|
|
}
|
|
if (url.includes("/metadata/myapp?list=true")) {
|
|
return jsonResponse({ data: { keys: ["prod"] } });
|
|
}
|
|
if (url.includes("/data/myapp/prod")) {
|
|
return jsonResponse({
|
|
data: { data: { API_KEY: "a", DB_PASSWORD: "b" } },
|
|
});
|
|
}
|
|
if (url.includes("/data/shared")) {
|
|
return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } });
|
|
}
|
|
return jsonResponse({}, false, 404);
|
|
});
|
|
|
|
const names = await hashicorpClient.listSecretNames?.(config);
|
|
|
|
expect(names).toEqual([
|
|
"myapp/prod:API_KEY",
|
|
"myapp/prod:DB_PASSWORD",
|
|
"shared:STRIPE_KEY",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("azure client", () => {
|
|
const config = {
|
|
providerType: "azure" as const,
|
|
vaultUri: "https://my-vault.vault.azure.net",
|
|
tenantId: "tenant-1",
|
|
clientId: "client-1",
|
|
clientSecret: "secret-1",
|
|
};
|
|
|
|
it("authenticates and reads secrets by name", async () => {
|
|
mockFetch.mockImplementation(async (url: string) => {
|
|
if (url.includes("login.microsoftonline.com/tenant-1")) {
|
|
return jsonResponse({ access_token: "azure-token" });
|
|
}
|
|
if (url.includes("/secrets/db-password?")) {
|
|
return jsonResponse({ value: "azure-pw" });
|
|
}
|
|
return jsonResponse({}, false, 404);
|
|
});
|
|
|
|
const result = await azureClient.getSecrets(config, ["db-password"]);
|
|
|
|
expect(result).toEqual({ "db-password": "azure-pw" });
|
|
});
|
|
|
|
it("throws a clear error for missing secrets", async () => {
|
|
mockFetch.mockImplementation(async (url: string) => {
|
|
if (url.includes("login.microsoftonline.com")) {
|
|
return jsonResponse({ access_token: "azure-token" });
|
|
}
|
|
return jsonResponse({}, false, 404);
|
|
});
|
|
|
|
await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow(
|
|
'secret "nope" not found',
|
|
);
|
|
});
|
|
|
|
it("lists secret names from paged results", async () => {
|
|
mockFetch.mockImplementation(async (url: string) => {
|
|
if (url.includes("login.microsoftonline.com")) {
|
|
return jsonResponse({ access_token: "azure-token" });
|
|
}
|
|
if (url.includes("skiptoken")) {
|
|
return jsonResponse({
|
|
value: [{ id: `${config.vaultUri}/secrets/second` }],
|
|
nextLink: null,
|
|
});
|
|
}
|
|
return jsonResponse({
|
|
value: [{ id: `${config.vaultUri}/secrets/first` }],
|
|
nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`,
|
|
});
|
|
});
|
|
|
|
const names = await azureClient.listSecretNames?.(config);
|
|
|
|
expect(names).toEqual(["first", "second"]);
|
|
});
|
|
});
|
|
|
|
describe("doppler client", () => {
|
|
it("propagates auth errors with the status code", async () => {
|
|
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
|
await expect(
|
|
dopplerClient.getSecrets(
|
|
{ providerType: "doppler", serviceToken: "bad" },
|
|
["FOO"],
|
|
),
|
|
).rejects.toThrow("status 401");
|
|
});
|
|
});
|