Add auth URL config module with validation (TDD)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
47
src/config/auth.test.ts
Normal file
47
src/config/auth.test.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { resolveAuthConfig } from './auth';
|
||||||
|
|
||||||
|
describe('resolveAuthConfig', () => {
|
||||||
|
it('returns both URLs and sales email from env', () => {
|
||||||
|
const cfg = resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
});
|
||||||
|
expect(cfg.signInUrl).toBe('https://auth.cameleer.io/sign-in');
|
||||||
|
expect(cfg.signUpUrl).toBe('https://auth.cameleer.io/sign-in?first_screen=register');
|
||||||
|
expect(cfg.salesEmail).toBe('sales@cameleer.io');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if PUBLIC_AUTH_SIGNIN_URL is missing', () => {
|
||||||
|
expect(() => resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
})).toThrow(/PUBLIC_AUTH_SIGNIN_URL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if a URL is not https', () => {
|
||||||
|
expect(() => resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'http://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
})).toThrow(/must be https/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws if sales email is not a valid mailto target', () => {
|
||||||
|
expect(() => resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'not-an-email',
|
||||||
|
})).toThrow(/PUBLIC_SALES_EMAIL/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes signUpUrl distinct from signInUrl', () => {
|
||||||
|
const cfg = resolveAuthConfig({
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL: 'https://auth.cameleer.io/sign-in',
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL: 'https://auth.cameleer.io/sign-in?first_screen=register',
|
||||||
|
PUBLIC_SALES_EMAIL: 'sales@cameleer.io',
|
||||||
|
});
|
||||||
|
expect(cfg.signUpUrl).not.toBe(cfg.signInUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
56
src/config/auth.ts
Normal file
56
src/config/auth.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export interface AuthConfig {
|
||||||
|
signInUrl: string;
|
||||||
|
signUpUrl: string;
|
||||||
|
salesEmail: string;
|
||||||
|
salesMailto: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnvLike {
|
||||||
|
PUBLIC_AUTH_SIGNIN_URL?: string;
|
||||||
|
PUBLIC_AUTH_SIGNUP_URL?: string;
|
||||||
|
PUBLIC_SALES_EMAIL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireHttps(name: string, value: string | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
if (!value.startsWith('https://')) {
|
||||||
|
throw new Error(`${name} must be https (got: ${value})`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireEmail(name: string, value: string | undefined): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
// RFC-5322-ish minimal check — we just want to catch typos, not validate against RFC.
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||||
|
throw new Error(`${name} must look like an email (got: ${value})`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuthConfig(env: EnvLike): AuthConfig {
|
||||||
|
const signInUrl = requireHttps('PUBLIC_AUTH_SIGNIN_URL', env.PUBLIC_AUTH_SIGNIN_URL);
|
||||||
|
const signUpUrl = requireHttps('PUBLIC_AUTH_SIGNUP_URL', env.PUBLIC_AUTH_SIGNUP_URL);
|
||||||
|
const salesEmail = requireEmail('PUBLIC_SALES_EMAIL', env.PUBLIC_SALES_EMAIL);
|
||||||
|
return {
|
||||||
|
signInUrl,
|
||||||
|
signUpUrl,
|
||||||
|
salesEmail,
|
||||||
|
salesMailto: `mailto:${salesEmail}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy accessor for Astro usage. Not evaluated at module load (so vitest can
|
||||||
|
// import this file without the PUBLIC_* env vars being set). Each call after
|
||||||
|
// the first returns the cached config.
|
||||||
|
let _cached: AuthConfig | null = null;
|
||||||
|
export function getAuthConfig(): AuthConfig {
|
||||||
|
if (_cached === null) {
|
||||||
|
_cached = resolveAuthConfig(import.meta.env as unknown as EnvLike);
|
||||||
|
}
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user