Files
caddy-proxy-manager/tests/integration/oauth-provider-sync.test.ts
fuomag9 3a16d6e9b1 Replace next-auth with Better Auth, migrate DB columns to camelCase
- Replace next-auth v5 beta with better-auth v1.6.2 (stable releases)
- Add multi-provider OAuth support with admin UI configuration
- New oauthProviders table with encrypted secrets (AES-256-GCM)
- Env var bootstrap (OAUTH_*) syncs to DB, UI-created providers fully editable
- OAuth provider REST API: GET/POST/PUT/DELETE /api/v1/oauth-providers
- Settings page "Authentication Providers" section for admin management
- Account linking uses new accounts table (multi-provider per user)
- Username plugin for credentials sign-in (replaces email@localhost pattern)
- bcrypt password compatibility (existing hashes work)
- Database-backed sessions via Kysely adapter (bun:sqlite direct)
- Configurable rate limiting via AUTH_RATE_LIMIT_* env vars
- All DB columns migrated from snake_case to camelCase
- All TypeScript types/models migrated to camelCase properties
- Removed casing: "snake_case" from Drizzle config
- Callback URL format: {baseUrl}/api/auth/oauth2/callback/{providerId}
- package-lock.json removed and gitignored (using bun.lock)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:11:48 +02:00

148 lines
4.5 KiB
TypeScript

import { describe, it, expect, beforeEach } from "vitest";
import { createTestDb, type TestDb } from "../helpers/db";
import { oauthProviders } from "@/src/lib/db/schema";
import { randomUUID } from "node:crypto";
import { encryptSecret, decryptSecret } from "@/src/lib/secret";
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
/**
* Simulates what syncEnvOAuthProviders does:
* - If no env-sourced provider with this name exists, create one
* - If env-sourced provider exists, update it
* - If UI-sourced provider with same name exists, skip
*/
async function syncProvider(envConfig: {
name: string;
clientId: string;
clientSecret: string;
issuer?: string | null;
autoLink?: boolean;
}) {
const now = nowIso();
const existing = await db.query.oauthProviders.findFirst({
where: (table, { eq }) => eq(table.name, envConfig.name),
});
if (existing && existing.source === "env") {
// Update existing env-sourced provider
const { eq } = await import("drizzle-orm");
await db.update(oauthProviders).set({
clientId: encryptSecret(envConfig.clientId),
clientSecret: encryptSecret(envConfig.clientSecret),
issuer: envConfig.issuer ?? null,
autoLink: envConfig.autoLink ?? false,
updatedAt: now,
}).where(eq(oauthProviders.id, existing.id));
} else if (!existing) {
// Create new env-sourced provider
await db.insert(oauthProviders).values({
id: randomUUID(),
name: envConfig.name,
type: "oidc",
clientId: encryptSecret(envConfig.clientId),
clientSecret: encryptSecret(envConfig.clientSecret),
issuer: envConfig.issuer ?? null,
authorizationUrl: null,
tokenUrl: null,
userinfoUrl: null,
scopes: "openid email profile",
autoLink: envConfig.autoLink ?? false,
enabled: true,
source: "env",
createdAt: now,
updatedAt: now,
});
}
// If a UI-sourced provider with the same name exists, skip
}
describe("syncEnvOAuthProviders", () => {
it("creates env-sourced provider when configured", async () => {
await syncProvider({
name: "TestIdP",
clientId: "env-client-id",
clientSecret: "env-client-secret",
issuer: "https://idp.example.com",
});
const providers = await db.query.oauthProviders.findMany();
expect(providers).toHaveLength(1);
expect(providers[0].name).toBe("TestIdP");
expect(providers[0].source).toBe("env");
expect(decryptSecret(providers[0].clientId)).toBe("env-client-id");
expect(providers[0].issuer).toBe("https://idp.example.com");
});
it("updates existing env-sourced provider when config changes", async () => {
// First sync
await syncProvider({
name: "MyIdP",
clientId: "old-id",
clientSecret: "old-secret",
issuer: "https://old.example.com",
autoLink: false,
});
// Second sync with changed config
await syncProvider({
name: "MyIdP",
clientId: "new-id",
clientSecret: "new-secret",
issuer: "https://new.example.com",
autoLink: true,
});
const providers = await db.query.oauthProviders.findMany();
expect(providers).toHaveLength(1);
expect(decryptSecret(providers[0].clientId)).toBe("new-id");
expect(providers[0].issuer).toBe("https://new.example.com");
expect(providers[0].autoLink).toBe(true);
});
it("does not overwrite a UI-sourced provider with the same name", async () => {
const now = nowIso();
// Create a UI-sourced provider first
await db.insert(oauthProviders).values({
id: randomUUID(),
name: "SharedName",
type: "oidc",
clientId: encryptSecret("ui-id"),
clientSecret: encryptSecret("ui-secret"),
scopes: "openid email profile",
autoLink: false,
enabled: true,
source: "ui",
createdAt: now,
updatedAt: now,
});
// Try to sync env with the same name
await syncProvider({
name: "SharedName",
clientId: "env-id",
clientSecret: "env-secret",
});
const providers = await db.query.oauthProviders.findMany();
expect(providers).toHaveLength(1);
// Should still be the UI provider, not overwritten
expect(providers[0].source).toBe("ui");
expect(decryptSecret(providers[0].clientId)).toBe("ui-id");
});
it("skips when OAuth is not configured (no providers created)", async () => {
// Simply don't call syncProvider - verify empty
const providers = await db.query.oauthProviders.findMany();
expect(providers).toHaveLength(0);
});
});