Skip to content
Merged
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
32 changes: 31 additions & 1 deletion .github/workflows/upgrade-integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ on:
description: 'Target version to upgrade to (version B). Leave empty to use the highest available.'
required: false
default: ''
target_image:
description: 'Image for version B instead of dokploy/dokploy:<target>, e.g. ghcr.io/agenthits/dokploy:agenthits-dev-<sha>. Pairs still come from official tags below target_version.'
required: false
default: ''

env:
DOKPLOY_IMAGE: dokploy/dokploy
Expand All @@ -30,6 +34,7 @@ env:

permissions:
contents: read
packages: read

# ──────────────────────────────────────────────────────────────────────────────
jobs:
Expand Down Expand Up @@ -332,10 +337,18 @@ jobs:
echo "✅ All user services healthy before upgrade"

# ── Upgrade ────────────────────────────────────────────────────────────
- name: Log in to GHCR for the target image
if: ${{ startsWith(inputs.target_image, 'ghcr.io/') }}
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

- name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }})
env:
TARGET_IMAGE: ${{ inputs.target_image || format('{0}:{1}', env.DOKPLOY_IMAGE, matrix.pair.to) }}
run: |
echo "Upgrading to ${TARGET_IMAGE}"
docker service update \
--image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \
--image "${TARGET_IMAGE}" \
--with-registry-auth \
--force \
"${{ env.DOKPLOY_SERVICE }}"

Expand Down Expand Up @@ -364,6 +377,23 @@ jobs:
'
echo "✅ Dokploy API is up after upgrade"

# A schema the auth library rejects still serves pages but fails every
# sign-in, so check a fresh sign-in of the admin created before the upgrade.
- name: Sign in with the pre-upgrade admin
run: |
JAR="$RUNNER_TEMP/post-upgrade-cookies.txt"
: > "$JAR"
chmod 600 "$JAR"
curl -sSf -X POST \
"http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-in/email" \
-H "Content-Type: application/json" \
-c "$JAR" \
-d '{"email":"ci@dokploy.test","password":"CiTest1234!"}' > /dev/null
curl -sSf "http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/get-session" \
-b "$JAR" > /tmp/post-upgrade-session.json
jq -e '.user.email == "ci@dokploy.test"' /tmp/post-upgrade-session.json
echo "✅ Pre-upgrade admin can sign in after the upgrade"

# ── Post-upgrade health check ──────────────────────────────────────────
- name: Verify all services still healthy (post-upgrade)
env:
Expand Down
212 changes: 212 additions & 0 deletions apps/dokploy/__test__/auth/api-key-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ const { resolveTrustedOriginsForAuthRequest } = await import(
const { canProvisionSsoMembershipForEmail } = await import(
"../../../../packages/server/src/lib/auth"
);
const { drizzleAdapter } = await import("better-auth/adapters/drizzle");
const { organization } = await import("better-auth/plugins");

// Captured before any beforeEach clears the mock call history.
const drizzleAdapterOptions = vi.mocked(drizzleAdapter).mock.calls[0]?.[1];
const organizationOptions = vi.mocked(organization).mock.calls[0]?.[0];

const apiKeyRequest = {
headers: {
Expand Down Expand Up @@ -218,6 +224,79 @@ describe("validateRequest API key sessions", () => {
});
});

describe("validateRequest cookie sessions", () => {
const cookieRequest = {
headers: { cookie: "better-auth.session_token=token" },
} as unknown as IncomingMessage;

beforeEach(() => {
vi.clearAllMocks();
mocks.getSession.mockResolvedValue({
session: {
id: "session-1",
userId: "user-1",
activeOrganizationId: "org-1",
},
user: { id: "user-1", email: "ada@example.com" },
});
});

it("resolves ownerId to the owner of the active organization", async () => {
mocks.memberFindFirst.mockResolvedValue({
role: "admin",
organization: { id: "org-1", ownerId: "owner-1" },
user: { enableEnterpriseFeatures: true, isValidEnterpriseLicense: true },
});

await expect(validateRequest(cookieRequest)).resolves.toMatchObject({
session: { id: "session-1", activeOrganizationId: "org-1" },
user: {
id: "user-1",
role: "admin",
ownerId: "owner-1",
enableEnterpriseFeatures: true,
isValidEnterpriseLicense: true,
},
});
});

it("falls back to the user itself when there is no membership", async () => {
mocks.memberFindFirst.mockResolvedValue(undefined);

await expect(validateRequest(cookieRequest)).resolves.toMatchObject({
session: { activeOrganizationId: "" },
user: {
id: "user-1",
role: "member",
ownerId: "user-1",
enableEnterpriseFeatures: false,
isValidEnterpriseLicense: false,
},
});
});
});

describe("Better Auth 1.7 configuration", () => {
it("runs the Drizzle adapter with native transactions", () => {
expect(drizzleAdapterOptions).toMatchObject({
provider: "pg",
transaction: true,
});
});

it("does not declare ownerId as a Better Auth user column", () => {
expect(mocks.authOptions.user.additionalFields).not.toHaveProperty(
"ownerId",
);
});

it("declares the NOT NULL organization ownerId column", () => {
expect(
organizationOptions?.schema?.organization?.additionalFields?.ownerId,
).toMatchObject({ type: "string", required: true, input: false });
});
});

describe("Better Auth trusted origins", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -348,6 +427,92 @@ describe("Better Auth SSO domain verification", () => {
enabled: true,
});
});

it("keeps IdP-initiated SAML disabled", () => {
expect(mocks.ssoPluginOptions.saml).toEqual({ allowIdpInitiated: false });
});
});

describe("Better Auth SSO identity gate", () => {
const validate = (email: string, source: Record<string, unknown>) =>
mocks.authOptions.user.validateUserInfo({ user: { email }, source }, {});

beforeEach(() => {
vi.clearAllMocks();
mocks.ssoProviderFindFirst.mockResolvedValue({
organizationId: "org-acme",
domain: "acme.com",
domainVerified: true,
});
});

it.each(["sso-oidc", "sso-saml"])(
"rejects %s identities outside the provider domains before the user is created",
async (method) => {
await expect(
validate("attacker@evil.com", {
method,
action: "create-user",
sso: { providerId: "acme-sso" },
}),
).resolves.toMatchObject({ error: "sso_email_domain_not_allowed" });
},
);

it("rejects linking an SSO account through an unverified provider domain", async () => {
mocks.ssoProviderFindFirst.mockResolvedValue({
organizationId: "org-acme",
domain: "acme.com",
domainVerified: false,
});

await expect(
validate("ada@acme.com", {
method: "sso-oidc",
action: "link-account",
sso: { providerId: "acme-sso" },
}),
).resolves.toMatchObject({ error: "sso_email_domain_not_allowed" });
});

it("rejects identities from an unknown SSO provider", async () => {
mocks.ssoProviderFindFirst.mockResolvedValue(undefined);

await expect(
validate("ada@acme.com", {
method: "sso-saml",
action: "create-user",
sso: { providerId: "missing" },
}),
).resolves.toMatchObject({ error: "sso_email_domain_not_allowed" });
});

it("allows SSO identities from a verified provider domain", async () => {
await expect(
validate("ada@engineering.acme.com", {
method: "sso-oidc",
action: "create-user",
sso: { providerId: "acme-sso" },
}),
).resolves.toBeUndefined();
});

it("does not gate returning SSO sign-ins or non-SSO identities", async () => {
await expect(
validate("ada@evil.com", {
method: "sso-oidc",
action: "sign-in",
sso: { providerId: "acme-sso" },
}),
).resolves.toBeUndefined();
await expect(
validate("ada@evil.com", {
method: "email-password",
action: "create-user",
}),
).resolves.toBeUndefined();
expect(mocks.ssoProviderFindFirst).not.toHaveBeenCalled();
});
});

describe("Better Auth SSO membership provisioning", () => {
Expand Down Expand Up @@ -445,6 +610,49 @@ describe("Better Auth SSO membership provisioning", () => {
).resolves.toBe("member");
});

it("assigns the organization default role through Better Auth provisioning", async () => {
mocks.organizationFindFirst.mockResolvedValueOnce({ defaultRole: "admin" });

await expect(
mocks.ssoPluginOptions.organizationProvisioning.getRole({
user: {
id: "user-sso",
email: "ada@acme.com",
},
userInfo: {},
provider: {
providerId: "acme-sso",
organizationId: "org-acme",
domain: "acme.com",
domainVerified: true,
},
}),
).resolves.toBe("admin");
});

it("does not insert a second membership when the SSO user already belongs to the organization", async () => {
mocks.memberFindFirst
.mockResolvedValueOnce({ role: "owner" })
.mockResolvedValueOnce({ id: "member-1" });

await expect(
mocks.authOptions.databaseHooks.user.create.after(
{
id: "user-sso",
email: "ada@acme.com",
},
{
path: "/sso/callback/acme-sso",
params: {
providerId: "acme-sso",
},
},
),
).resolves.toBeUndefined();

expect(mocks.insert).not.toHaveBeenCalled();
});

it("fails closed before inserting SSO membership when email domain mismatches provider domains", async () => {
await expect(
mocks.authOptions.databaseHooks.user.create.after(
Expand All @@ -466,6 +674,10 @@ describe("Better Auth SSO membership provisioning", () => {
});

it("provisions SSO membership only after the local email domain check passes", async () => {
mocks.memberFindFirst
.mockResolvedValueOnce({ role: "owner" })
.mockResolvedValueOnce(undefined);

await expect(
mocks.authOptions.databaseHooks.user.create.after(
{
Expand Down
50 changes: 50 additions & 0 deletions apps/dokploy/__test__/auth/better-auth-schema-compat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";

type SchemaCheckContext = {
checkSchema?: () => Promise<void> | undefined;
};

const captured = vi.hoisted(() => ({
contexts: [] as Promise<unknown>[],
}));

vi.mock("better-auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("better-auth")>();
const betterAuth: typeof actual.betterAuth = (options) => {
const instance = actual.betterAuth(options);
captured.contexts.push(instance.$context);
return instance;
};
return { ...actual, betterAuth };
});

const loadAuthContext = async (isCloud: boolean) => {
vi.resetModules();
vi.doMock("@dokploy/server/constants", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
IS_CLOUD: isCloud,
}));
await import("../../../../packages/server/src/lib/auth");
const context = captured.contexts.at(-1);
if (!context) {
throw new Error("betterAuth() was not called while loading lib/auth");
}
return (await context) as SchemaCheckContext;
};

describe("Better Auth schema compatibility", () => {
// Better Auth checks the Drizzle schema before serving any request and
// blocks authentication when a column it writes is missing or a required
// column is unknown to it, which would take the whole dashboard down.
it.each([false, true])(
"accepts the Drizzle schema when IS_CLOUD is %s",
async (isCloud) => {
const context = await loadAuthContext(isCloud);

expect(context.checkSchema).toBeTypeOf("function");
await expect(
Promise.resolve(context.checkSchema?.()),
).resolves.toBeUndefined();
},
);
});
Loading
Loading