feat(phase2): phase 6 - integration & polish

Fix all build/type/lint errors, write 60 new tests (175 total),
update seed with new widget types and team board permissions,
install missing svelte-i18n dependency, fix DynamicIcon for Svelte 5.
This commit is contained in:
2026-03-24 23:43:31 +03:00
parent 5bb4fbcedf
commit 87ed928a3a
17 changed files with 2057 additions and 59 deletions
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../prisma.js', () => ({
prisma: {
board: {
findUnique: vi.fn()
},
section: {
findUnique: vi.fn(),
update: vi.fn()
},
widget: {
findUnique: vi.fn(),
update: vi.fn()
},
$transaction: vi.fn()
}
}));
import { prisma } from '../../prisma.js';
import { reorderSections, reorderWidgets, moveWidget } from '../boardService.js';
const mockBoard = prisma.board as unknown as Record<string, ReturnType<typeof vi.fn>>;
const mockSection = prisma.section as unknown as Record<string, ReturnType<typeof vi.fn>>;
const mockWidget = prisma.widget as unknown as Record<string, ReturnType<typeof vi.fn>>;
const mockPrisma = prisma as unknown as { $transaction: ReturnType<typeof vi.fn> };
describe('Board reorder operations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('reorderSections', () => {
it('reorders sections by updating their order', async () => {
mockBoard.findUnique.mockResolvedValue({ id: 'b1', sections: [] });
mockPrisma.$transaction.mockResolvedValue([]);
await reorderSections('b1', ['s3', 's1', 's2']);
expect(mockPrisma.$transaction).toHaveBeenCalledOnce();
// The transaction should receive an array of update operations
const transactionArg = mockPrisma.$transaction.mock.calls[0][0];
expect(transactionArg).toHaveLength(3);
});
it('throws when board does not exist', async () => {
mockBoard.findUnique.mockResolvedValue(null);
await expect(reorderSections('missing', ['s1'])).rejects.toThrow('Board not found');
});
it('handles single section reorder', async () => {
mockBoard.findUnique.mockResolvedValue({ id: 'b1' });
mockPrisma.$transaction.mockResolvedValue([]);
await reorderSections('b1', ['s1']);
const transactionArg = mockPrisma.$transaction.mock.calls[0][0];
expect(transactionArg).toHaveLength(1);
});
});
describe('reorderWidgets', () => {
it('reorders widgets within a section', async () => {
mockSection.findUnique.mockResolvedValue({ id: 's1', widgets: [] });
mockPrisma.$transaction.mockResolvedValue([]);
await reorderWidgets('s1', ['w2', 'w1', 'w3']);
expect(mockPrisma.$transaction).toHaveBeenCalledOnce();
const transactionArg = mockPrisma.$transaction.mock.calls[0][0];
expect(transactionArg).toHaveLength(3);
});
it('throws when section does not exist', async () => {
mockSection.findUnique.mockResolvedValue(null);
await expect(reorderWidgets('missing', ['w1'])).rejects.toThrow('Section not found');
});
it('handles empty widget list', async () => {
mockSection.findUnique.mockResolvedValue({ id: 's1' });
mockPrisma.$transaction.mockResolvedValue([]);
await reorderWidgets('s1', []);
const transactionArg = mockPrisma.$transaction.mock.calls[0][0];
expect(transactionArg).toHaveLength(0);
});
});
describe('moveWidget', () => {
it('moves a widget to a different section', async () => {
mockWidget.findUnique.mockResolvedValue({ id: 'w1', sectionId: 's1' });
mockSection.findUnique.mockResolvedValue({ id: 's2', widgets: [] });
mockWidget.update.mockResolvedValue({ id: 'w1', sectionId: 's2', order: 0 });
const result = await moveWidget('w1', 's2', 0);
expect(result.sectionId).toBe('s2');
expect(result.order).toBe(0);
expect(mockWidget.update).toHaveBeenCalledWith({
where: { id: 'w1' },
data: { sectionId: 's2', order: 0 }
});
});
it('throws when widget does not exist', async () => {
mockWidget.findUnique.mockResolvedValue(null);
await expect(moveWidget('missing', 's2', 0)).rejects.toThrow('Widget not found');
});
it('throws when target section does not exist', async () => {
mockWidget.findUnique.mockResolvedValue({ id: 'w1' });
mockSection.findUnique.mockResolvedValue(null);
await expect(moveWidget('w1', 'missing', 0)).rejects.toThrow('Section not found');
});
});
});
@@ -0,0 +1,250 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock openid-client
vi.mock('openid-client', () => ({
randomPKCECodeVerifier: vi.fn(() => 'mock-verifier-abc123'),
calculatePKCECodeChallenge: vi.fn(async () => 'mock-challenge-xyz789'),
discovery: vi.fn(),
buildAuthorizationUrl: vi.fn(),
authorizationCodeGrant: vi.fn(),
fetchUserInfo: vi.fn(),
randomState: vi.fn(() => 'mock-state-123')
}));
// Mock prisma
vi.mock('../../prisma.js', () => ({
prisma: {
systemSettings: {
findUnique: vi.fn()
}
}
}));
import * as client from 'openid-client';
import { prisma } from '../../prisma.js';
import {
invalidateOAuthCache,
generateCodeVerifier,
calculateCodeChallenge,
generateAuthUrl,
handleCallback,
testConnection
} from '../oauthService.js';
const mockSettings = prisma.systemSettings as unknown as Record<string, ReturnType<typeof vi.fn>>;
const mockClient = client as unknown as Record<string, ReturnType<typeof vi.fn>>;
// Helper to set up OAuth config in DB
function setupOAuthSettings(overrides: Record<string, string | null> = {}) {
mockSettings.findUnique.mockResolvedValue({
id: 'singleton',
oauthClientId: overrides.oauthClientId ?? 'test-client-id',
oauthClientSecret: overrides.oauthClientSecret ?? 'test-client-secret',
oauthDiscoveryUrl:
overrides.oauthDiscoveryUrl ?? 'https://auth.example.com/.well-known/openid-configuration'
});
}
// Mock OIDC configuration object
function createMockOIDCConfig() {
return {
serverMetadata: () => ({
issuer: 'https://auth.example.com',
supportsPKCE: () => true
})
};
}
describe('oauthService', () => {
beforeEach(() => {
vi.clearAllMocks();
invalidateOAuthCache();
});
describe('generateCodeVerifier', () => {
it('returns a PKCE code verifier', () => {
const verifier = generateCodeVerifier();
expect(verifier).toBe('mock-verifier-abc123');
expect(mockClient.randomPKCECodeVerifier).toHaveBeenCalledOnce();
});
});
describe('calculateCodeChallenge', () => {
it('returns a PKCE code challenge', async () => {
const challenge = await calculateCodeChallenge('my-verifier');
expect(challenge).toBe('mock-challenge-xyz789');
expect(mockClient.calculatePKCECodeChallenge).toHaveBeenCalledWith('my-verifier');
});
});
describe('generateAuthUrl', () => {
it('builds authorization URL with PKCE', async () => {
setupOAuthSettings();
const mockConfig = createMockOIDCConfig();
mockClient.discovery.mockResolvedValue(mockConfig);
mockClient.buildAuthorizationUrl.mockReturnValue(
new URL('https://auth.example.com/authorize?code_challenge=abc')
);
const url = await generateAuthUrl('https://app.example.com/callback', 'test-challenge');
expect(url).toBe('https://auth.example.com/authorize?code_challenge=abc');
expect(mockClient.buildAuthorizationUrl).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
redirect_uri: 'https://app.example.com/callback',
scope: 'openid profile email',
code_challenge: 'test-challenge',
code_challenge_method: 'S256'
})
);
});
it('throws when OAuth is not configured', async () => {
mockSettings.findUnique.mockResolvedValue(null);
// Clear env vars
const origClientId = process.env.OAUTH_CLIENT_ID;
const origSecret = process.env.OAUTH_CLIENT_SECRET;
const origDiscovery = process.env.OAUTH_DISCOVERY_URL;
delete process.env.OAUTH_CLIENT_ID;
delete process.env.OAUTH_CLIENT_SECRET;
delete process.env.OAUTH_DISCOVERY_URL;
await expect(
generateAuthUrl('https://app.example.com/callback', 'challenge')
).rejects.toThrow('OAuth is not configured');
// Restore
process.env.OAUTH_CLIENT_ID = origClientId;
process.env.OAUTH_CLIENT_SECRET = origSecret;
process.env.OAUTH_DISCOVERY_URL = origDiscovery;
});
it('adds state when provider does not support PKCE', async () => {
setupOAuthSettings();
const mockConfig = {
serverMetadata: () => ({
issuer: 'https://auth.example.com',
supportsPKCE: () => false
})
};
mockClient.discovery.mockResolvedValue(mockConfig);
mockClient.buildAuthorizationUrl.mockReturnValue(
new URL('https://auth.example.com/authorize')
);
await generateAuthUrl('https://app.example.com/callback', 'test-challenge');
expect(mockClient.buildAuthorizationUrl).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({
state: 'mock-state-123'
})
);
});
});
describe('handleCallback', () => {
it('exchanges code for tokens and returns user info', async () => {
setupOAuthSettings();
const mockConfig = createMockOIDCConfig();
mockClient.discovery.mockResolvedValue(mockConfig);
mockClient.authorizationCodeGrant.mockResolvedValue({
access_token: 'test-access-token',
claims: () => ({ sub: 'user-sub-123' })
});
mockClient.fetchUserInfo.mockResolvedValue({
sub: 'user-sub-123',
email: 'user@example.com',
name: 'Test User',
preferred_username: 'testuser',
picture: 'https://example.com/avatar.jpg',
groups: ['admin', 'users']
});
const result = await handleCallback(
new URL('https://app.example.com/callback?code=abc'),
'test-verifier'
);
expect(result).toEqual({
sub: 'user-sub-123',
email: 'user@example.com',
name: 'Test User',
preferred_username: 'testuser',
picture: 'https://example.com/avatar.jpg',
groups: ['admin', 'users']
});
});
it('throws when sub is missing from token claims', async () => {
setupOAuthSettings();
const mockConfig = createMockOIDCConfig();
mockClient.discovery.mockResolvedValue(mockConfig);
mockClient.authorizationCodeGrant.mockResolvedValue({
access_token: 'test-access-token',
claims: () => ({})
});
await expect(
handleCallback(
new URL('https://app.example.com/callback?code=abc'),
'test-verifier'
)
).rejects.toThrow('subject claim');
});
it('throws when email is missing from user info', async () => {
setupOAuthSettings();
const mockConfig = createMockOIDCConfig();
mockClient.discovery.mockResolvedValue(mockConfig);
mockClient.authorizationCodeGrant.mockResolvedValue({
access_token: 'test-access-token',
claims: () => ({ sub: 'user-sub-123' })
});
mockClient.fetchUserInfo.mockResolvedValue({
sub: 'user-sub-123'
// no email
});
await expect(
handleCallback(
new URL('https://app.example.com/callback?code=abc'),
'test-verifier'
)
).rejects.toThrow('email');
});
});
describe('testConnection', () => {
it('returns the issuer on successful discovery', async () => {
setupOAuthSettings();
const mockConfig = createMockOIDCConfig();
mockClient.discovery.mockResolvedValue(mockConfig);
const issuer = await testConnection();
expect(issuer).toBe('https://auth.example.com');
});
});
describe('invalidateOAuthCache', () => {
it('forces re-discovery on next call', async () => {
setupOAuthSettings();
const mockConfig = createMockOIDCConfig();
mockClient.discovery.mockResolvedValue(mockConfig);
// First call triggers discovery
await testConnection();
expect(mockClient.discovery).toHaveBeenCalledTimes(1);
// Second call uses cache
await testConnection();
expect(mockClient.discovery).toHaveBeenCalledTimes(1);
// After invalidation, discovery is called again
invalidateOAuthCache();
await testConnection();
expect(mockClient.discovery).toHaveBeenCalledTimes(2);
});
});
});
+5 -1
View File
@@ -142,7 +142,11 @@ export async function handleCallback(
});
// Try to get user info from the userinfo endpoint
const userInfo = await client.fetchUserInfo(config, tokens.access_token, tokens.claims()?.sub);
const sub = tokens.claims()?.sub;
if (!sub) {
throw new Error('OAuth token response did not include a subject claim (sub).');
}
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
const email = (userInfo.email as string) || '';
if (!email) {