import { describe, it, expect } from 'vitest'; import { generate, buildBackendPatches, buildAuthLiteral, injectProviderAuth, } from '../generate.js'; import { ConfigureLoginInputSchema, type ConfigureLoginInput } from '../types.js'; import { deepMerge } from '../../../../lib/json-merge.js'; /** Parse through the schema so defaults (secretsMode, frontendAuth, …) apply. */ function spec(overrides: Record = {}): ConfigureLoginInput { return ConfigureLoginInputSchema.parse({ projectPath: '/proj', appCode: 'demo', connectionTypes: { local: true }, allowRegistration: true, ...overrides, }); } const LAYOUT = { apiDir: 'src/Demo.Api', webDir: 'web/demo-web' }; // A realistic slice of the generated appsettings.json template — the merge must // preserve every one of these keys. const EXISTING_APPSETTINGS = { Jwt: { Issuer: 'SmartStack', Audience: 'SmartStack', AccessTokenExpirationMinutes: 60 }, Authentication: { Microsoft: { ClientId: '', ClientSecret: '', CallbackPath: '/api/auth/microsoft/callback' }, Google: { ClientId: '', ClientSecret: '', CallbackPath: '/api/auth/google/callback' }, EntraSso: { Enabled: false, ClientId: '', AllowedIssuers: [], ClockSkewSeconds: 60 }, }, Session: { IdleTimeoutMinutes: 20, MaxConcurrentSessions: 1 }, Email: { Provider: 'Development', FromName: 'SmartStack', TokenExpiration: { PasswordReset: '01:00:00' } }, Security: { LockoutWindowMinutes: 15, MaxFailedAttempts: 5, InitialAdmin: { Email: '', Password: '' } }, Localization: { DefaultLanguage: 'fr' }, }; // The canonical main.tsx the init generator emits (provider block verbatim). const CANONICAL_MAIN = `import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { SmartStackProvider } from '@atlashub/smartstack'; import App from './App'; createRoot(document.getElementById('root')!).render( , ); `; describe('buildBackendPatches — deep-merge preservation', () => { it('preserves every unrelated key and sub-key when merged', () => { const { committed } = buildBackendPatches( spec({ connectionTypes: { local: true, microsoft: { clientId: 'ms-id', clientSecret: 'ms-secret' }, entra: { clientId: 'entra-id', allowedTenantIds: ['tenant-a'] }, }, allowRegistration: false, email: { provider: 'Smtp', fromEmail: 'no-reply@x.io', fromName: 'X', smtp: { host: 'mail.x.io' } }, }), ); const merged = deepMerge(EXISTING_APPSETTINGS, committed) as any; // Untouched sections survive. expect(merged.Jwt.AccessTokenExpirationMinutes).toBe(60); expect(merged.Session.IdleTimeoutMinutes).toBe(20); expect(merged.Localization.DefaultLanguage).toBe('fr'); // Untouched sub-keys of touched sections survive. expect(merged.Authentication.Microsoft.CallbackPath).toBe('/api/auth/microsoft/callback'); expect(merged.Authentication.Google.CallbackPath).toBe('/api/auth/google/callback'); expect(merged.Security.LockoutWindowMinutes).toBe(15); expect(merged.Email.TokenExpiration.PasswordReset).toBe('01:00:00'); expect(merged.Authentication.EntraSso.ClockSkewSeconds).toBe(60); // New values applied. expect(merged.Authentication.Microsoft.ClientId).toBe('ms-id'); expect(merged.Authentication.EntraSso.ClientId).toBe('entra-id'); expect(merged.Authentication.Microsoft.AllowedTenants).toEqual(['tenant-a']); expect(merged.Email.Provider).toBe('Smtp'); expect(merged.Email.Smtp.Host).toBe('mail.x.io'); }); it('routes the Entra tenant whitelist to Authentication:Microsoft:AllowedTenants (replaced wholesale)', () => { const { committed } = buildBackendPatches( spec({ connectionTypes: { local: true, entra: { clientId: 'e', allowedTenantIds: ['only-this'] } } }), ); const merged = deepMerge(EXISTING_APPSETTINGS, committed) as any; expect(merged.Authentication.Microsoft.AllowedTenants).toEqual(['only-this']); expect(merged.Authentication.EntraSso.AllowedTenantIds).toBeUndefined(); }); }); describe('buildBackendPatches — provider toggles', () => { it('includes Microsoft only when configured', () => { const on = buildBackendPatches(spec({ connectionTypes: { local: true, microsoft: { clientId: 'm', clientSecret: 's' } } })); expect((on.committed as any).Authentication.Microsoft.ClientId).toBe('m'); const off = buildBackendPatches(spec({ connectionTypes: { local: true } })); expect((off.committed as any).Authentication).toBeUndefined(); }); it('includes Google only when configured', () => { const on = buildBackendPatches(spec({ connectionTypes: { local: true, google: { clientId: 'g', clientSecret: 's' } } })); expect((on.committed as any).Authentication.Google.ClientId).toBe('g'); }); it('includes EntraSso nested under Authentication only when configured, enabled default true', () => { const on = buildBackendPatches(spec({ connectionTypes: { local: true, entra: { clientId: 'e' } } })); expect((on.committed as any).Authentication.EntraSso).toEqual({ Enabled: true, ClientId: 'e' }); const off = buildBackendPatches(spec({ connectionTypes: { local: true } })); expect((off.committed as any).Authentication).toBeUndefined(); }); it('local-only with no admin/email carries NO sections at all', () => { const { committed, local } = buildBackendPatches(spec({ connectionTypes: { local: true } })); expect((committed as any).Authentication).toBeUndefined(); expect((committed as any).Security).toBeUndefined(); expect((committed as any).Email).toBeUndefined(); expect(local).toBeNull(); }); }); describe('connection-types refinement', () => { it('rejects switching the local password form off', () => { // It used to be a plain boolean that silently changed nothing: the backend has no switch for // it. It is also how the initial administrator signs in, and the only provider depending on no // external system — an app able to turn it off could lock itself out of its own administration. const res = ConfigureLoginInputSchema.safeParse({ projectPath: '/p', appCode: 'a', connectionTypes: { local: false }, allowRegistration: true, }); expect(res.success).toBe(false); expect(JSON.stringify((res as { error?: unknown }).error)).toContain('cannot be disabled'); }); it('defaults local to true when omitted', () => { const res = ConfigureLoginInputSchema.parse({ projectPath: '/p', appCode: 'a', connectionTypes: {}, allowRegistration: true, }); expect(res.connectionTypes.local).toBe(true); }); }); describe('provider on/off switches', () => { // Availability has always meant "credentials present" — the backend simply returned early // without them. `Enabled` adds the ability to say no while KEEPING the credentials, instead of // erasing a secret you would then have to go and fetch again. it('writes Enabled:true alongside the credentials by default', () => { const { committed } = buildBackendPatches( spec({ connectionTypes: { local: true, microsoft: { clientId: 'm', clientSecret: 's' } } }), ); expect((committed as any).Authentication.Microsoft.Enabled).toBe(true); expect((committed as any).Authentication.Microsoft.ClientId).toBe('m'); }); it('writes Enabled:false while keeping the credentials', () => { const { committed, local } = buildBackendPatches( spec({ connectionTypes: { local: true, google: { clientId: 'g', clientSecret: 'shhh', enabled: false } }, }), ); expect((committed as any).Authentication.Google.Enabled).toBe(false); expect((committed as any).Authentication.Google.ClientId).toBe('g'); expect((local as any).Authentication.Google.ClientSecret).toBe('shhh'); }); it('carries the Entra switch through unchanged', () => { const { committed } = buildBackendPatches( spec({ connectionTypes: { local: true, entra: { clientId: 'e', enabled: false } } }), ); expect((committed as any).Authentication.EntraSso.Enabled).toBe(false); }); }); describe('local password for directory-managed accounts', () => { // Authentication:LocalPassword:AllowForFederatedAccounts. A federated account that keeps a usable // local password survives its own offboarding: revoking the identity in the directory revokes // neither the password held by SmartStack nor the conditional access it believes it enforces. it('says nothing when left at the package default', () => { // The generated appsettings.json template already carries `true`; restating it would be noise, // and this generator writes only what was actually decided. const { committed } = buildBackendPatches( spec({ connectionTypes: { local: true, entra: { clientId: 'e' } } }), ); expect((committed as any).Authentication.LocalPassword).toBeUndefined(); }); it('writes the key when the door is closed to federated accounts', () => { const { committed } = buildBackendPatches( spec({ connectionTypes: { local: true, entra: { clientId: 'e' } }, allowLocalPasswordForFederatedAccounts: false, }), ); expect((committed as any).Authentication.LocalPassword).toEqual({ AllowForFederatedAccounts: false }); }); it('does not resurrect an Authentication section for a local-only project', () => { const { committed } = buildBackendPatches( spec({ connectionTypes: { local: true } }), ); expect((committed as any).Authentication).toBeUndefined(); }); }); describe('self-registration is never written to the backend', () => { it('never emits Security:EnableSelfRegistration (the backend has no such flag)', () => { for (const allow of [true, false]) { const { committed } = buildBackendPatches(spec({ allowRegistration: allow })); expect(JSON.stringify(committed)).not.toContain('EnableSelfRegistration'); } }); it('still reflects allowRegistration in the (gated) frontend auth literal', () => { expect(buildAuthLiteral(spec({ allowRegistration: false }))).toContain('allowRegistration: false'); expect(buildAuthLiteral(spec({ allowRegistration: true }))).toContain('allowRegistration: true'); }); }); describe('branding + initialAdmin', () => { it('emits branding into the auth literal when provided, omits when absent', () => { const withBrand = buildAuthLiteral(spec({ branding: { appName: 'Acme Portal' } })); expect(withBrand).toContain('branding: { appName: "Acme Portal" }'); expect(buildAuthLiteral(spec())).not.toContain('branding:'); }); it('emits no Security section when no initial admin is supplied', () => { const none = buildBackendPatches(spec()); expect((none.committed as any).Security).toBeUndefined(); }); it('emits InitialAdmin (email committed, password local) when supplied', () => { const withAdmin = buildBackendPatches(spec({ initialAdmin: { email: 'a@x.io', password: 'pw' } })); expect((withAdmin.committed as any).Security.InitialAdmin.Email).toBe('a@x.io'); expect((withAdmin.committed as any).Security.InitialAdmin.Password).toBeUndefined(); expect((withAdmin.local as any).Security.InitialAdmin.Password).toBe('pw'); }); it('carries RequirePasswordChange only when set', () => { const on = buildBackendPatches(spec({ initialAdmin: { email: 'a@x.io', password: 'pw', requirePasswordChange: true } })); expect((on.committed as any).Security.InitialAdmin.RequirePasswordChange).toBe(true); const off = buildBackendPatches(spec({ initialAdmin: { email: 'a@x.io', password: 'pw' } })); expect((off.committed as any).Security.InitialAdmin.RequirePasswordChange).toBeUndefined(); }); }); describe('email — provider settings + secret partition', () => { it('Development: sender only, no secret, no Local', () => { const { committed, local } = buildBackendPatches( spec({ email: { provider: 'Development', fromEmail: 'f@x.io', fromName: 'F' } }), ); expect((committed as any).Email).toEqual({ Enabled: true, Provider: 'Development', FromEmail: 'f@x.io', FromName: 'F' }); expect(local).toBeNull(); }); it('Smtp: public fields committed, password partitioned to Local', () => { const { committed, local } = buildBackendPatches( spec({ email: { provider: 'Smtp', fromEmail: 'f@x.io', fromName: 'F', smtp: { host: 'smtp.x.io', port: 587, username: 'u@x.io', password: 'SMTP-PW', useSsl: true }, }, }), ); expect((committed as any).Email.Smtp).toEqual({ Host: 'smtp.x.io', Port: 587, Username: 'u@x.io', UseSsl: true }); expect(JSON.stringify(committed)).not.toContain('SMTP-PW'); expect((local as any).Email.Smtp.Password).toBe('SMTP-PW'); }); it('SendGrid: api key only in Local, nothing secret committed', () => { const { committed, local } = buildBackendPatches( spec({ email: { provider: 'SendGrid', fromEmail: 'f@x.io', fromName: 'F', sendGrid: { apiKey: 'SG-KEY' } } }), ); expect((committed as any).Email.Provider).toBe('SendGrid'); expect(JSON.stringify(committed)).not.toContain('SG-KEY'); expect((local as any).Email.SendGrid.ApiKey).toBe('SG-KEY'); }); it('AzureAcs: senderAddress committed, connection string in Local', () => { const { committed, local } = buildBackendPatches( spec({ email: { provider: 'AzureAcs', fromEmail: 'f@x.io', fromName: 'F', azureAcs: { connectionString: 'endpoint=...;accesskey=SECRET', senderAddress: 'no-reply@x.io' }, }, }), ); expect((committed as any).Email.AzureAcs).toEqual({ SenderAddress: 'no-reply@x.io' }); expect(JSON.stringify(committed)).not.toContain('SECRET'); expect((local as any).Email.AzureAcs.ConnectionString).toBe('endpoint=...;accesskey=SECRET'); }); it('placeholders mode: SMTP password is "" committed, no Local', () => { const { committed, local } = buildBackendPatches( spec({ secretsMode: 'placeholders', email: { provider: 'Smtp', fromEmail: 'f@x.io', fromName: 'F', smtp: { host: 'smtp.x.io', password: 'SMTP-PW' } }, }), ); expect(JSON.stringify(committed)).not.toContain('SMTP-PW'); expect((committed as any).Email.Smtp.Password).toBe(''); expect(local).toBeNull(); }); it('rejects a provider whose settings block is missing', () => { expect(ConfigureLoginInputSchema.safeParse({ projectPath: '/p', appCode: 'a', connectionTypes: { local: true }, allowRegistration: true, email: { provider: 'SendGrid', fromEmail: 'f@x.io', fromName: 'F' }, }).success).toBe(false); }); }); describe('secrets safety — local-file vs placeholders', () => { it('local-file: committed has NO secrets, local carries them', () => { const { committed, local } = buildBackendPatches( spec({ secretsMode: 'local-file', connectionTypes: { local: true, microsoft: { clientId: 'm', clientSecret: 'TOP-SECRET' } }, initialAdmin: { email: 'a@x.io', password: 'ADMIN-PW' }, }), ); const committedStr = JSON.stringify(committed); expect(committedStr).not.toContain('TOP-SECRET'); expect(committedStr).not.toContain('ADMIN-PW'); expect((committed as any).Authentication.Microsoft.ClientSecret).toBeUndefined(); expect((committed as any).Security.InitialAdmin.Password).toBeUndefined(); expect((local as any).Authentication.Microsoft.ClientSecret).toBe('TOP-SECRET'); expect((local as any).Security.InitialAdmin.Password).toBe('ADMIN-PW'); }); it('placeholders: secrets are "" in committed, local is null', () => { const { committed, local } = buildBackendPatches( spec({ secretsMode: 'placeholders', connectionTypes: { local: true, microsoft: { clientId: 'm', clientSecret: 'TOP-SECRET' } }, initialAdmin: { email: 'a@x.io', password: 'ADMIN-PW' }, }), ); expect(JSON.stringify(committed)).not.toContain('TOP-SECRET'); expect(JSON.stringify(committed)).not.toContain('ADMIN-PW'); expect((committed as any).Authentication.Microsoft.ClientSecret).toBe(''); expect((committed as any).Security.InitialAdmin.Password).toBe(''); expect(local).toBeNull(); }); }); describe('appsettings idempotency', () => { it('merging the patch twice equals merging once', () => { const { committed } = buildBackendPatches( spec({ connectionTypes: { local: true, entra: { clientId: 'e', allowedTenantIds: ['t'] } }, allowRegistration: false }), ); const once = deepMerge(EXISTING_APPSETTINGS, committed); const twice = deepMerge(once, committed); expect(twice).toEqual(once); }); }); describe('generate — emitted files', () => { it('local-file with a secret emits committed + Local, both deep-merge-json', () => { const files = generate(spec({ connectionTypes: { local: true, microsoft: { clientId: 'm', clientSecret: 's' } } }), LAYOUT); expect(files.map((f) => f.path)).toEqual(['src/Demo.Api/appsettings.json', 'src/Demo.Api/appsettings.Local.json']); expect(files.every((f) => f.strategy === 'deep-merge-json')).toBe(true); }); it('local-only emits a single committed file (no Local)', () => { const files = generate(spec({ connectionTypes: { local: true } }), LAYOUT); expect(files.map((f) => f.path)).toEqual(['src/Demo.Api/appsettings.json']); }); it('placeholders emits a single committed file even with providers', () => { const files = generate(spec({ secretsMode: 'placeholders', connectionTypes: { local: true, google: { clientId: 'g', clientSecret: 's' } } }), LAYOUT); expect(files.map((f) => f.path)).toEqual(['src/Demo.Api/appsettings.json']); }); }); describe('injectProviderAuth — main.tsx', () => { it('injects auth inside config={{ and keeps apiUrl + extensions', () => { const { content, status } = injectProviderAuth(CANONICAL_MAIN, spec({ connectionTypes: { local: true, entra: { clientId: 'e-id' } } })); expect(status).toBe('injected'); // The literal carries UI affordances only — never the Entra client id, which the server serves. expect(content).toContain('auth: { allowRegistration:'); expect(content).not.toContain('e-id'); expect(content).toContain('apiUrl: import.meta.env.VITE_API_URL'); expect(content).toContain('extensions: {}'); }); it('is idempotent — re-run updates in place, never duplicates the marker', () => { const s = spec({ connectionTypes: { local: true, entra: { clientId: 'e-id' } } }); const first = injectProviderAuth(CANONICAL_MAIN, s).content; const second = injectProviderAuth(first, s); expect(second.status).toBe('updated'); expect(second.content).toBe(first); expect((second.content.match(/@login-config:auth/g) ?? []).length).toBe(1); }); it('refreshes the block when the spec changes', () => { const a = injectProviderAuth(CANONICAL_MAIN, spec({ allowRegistration: true })).content; const b = injectProviderAuth(a, spec({ allowRegistration: false })); expect(b.content).toContain('allowRegistration: false'); expect(b.content).not.toContain('allowRegistration: true'); }); it('returns not-found (unchanged) when there is no provider config', () => { const src = 'export const x = 1;\n'; const { content, status } = injectProviderAuth(src, spec()); expect(status).toBe('not-found'); expect(content).toBe(src); }); it('respects the @customised marker', () => { const src = '// @customised\n' + CANONICAL_MAIN; const { content, status } = injectProviderAuth(src, spec()); expect(status).toBe('skipped-customised'); expect(content).toBe(src); }); }); /** * Entra SSO configures NOTHING on the frontend, and this guard says so. * * `buildEnvAppend` used to write `# VITE_MSAL_*` placeholders into the client's .env.example, and * `buildAuthLiteral` used to put the client id into main.tsx. Neither could work: Vite substitutes * `import.meta.env.VITE_*` when the bundle containing the expression is built — @atlashub/smartstack * itself — so the value was frozen at publish time, and `SmartStackConfig` has no `auth.entra` key * to read the other one from. Since 3.66 the browser gets both from GET /api/config/features. */ describe('Entra SSO emits no frontend configuration', () => { it('keeps the client id out of the injected provider config', () => { const literal = buildAuthLiteral( spec({ connectionTypes: { local: true, entra: { clientId: 'e-id', authority: 'https://login.microsoftonline.com/tid' } } }), ); expect(literal).not.toContain('entra'); expect(literal).not.toContain('e-id'); }); it('writes the client id to appsettings, which is what the server serves', () => { const { committed } = buildBackendPatches( spec({ connectionTypes: { local: true, entra: { clientId: 'e-id', authority: 'https://login.microsoftonline.com/tid' } } }), ); const entra = (committed as any).Authentication.EntraSso; expect(entra.ClientId).toBe('e-id'); expect(entra.Authority).toBe('https://login.microsoftonline.com/tid'); }); });