test(auth): add 68 unit tests for auth-ui, shared-auth, and shared-branding

- userAgent utils: parseUserAgent, getDeviceType, formatUserAgent (17 tests)
- guestWelcome utils: shouldShow, markSeen, reset (8 tests)
- jwtUtils: decodeToken, isTokenValid, getUserFromToken, B2B (27 tests)
- mana-apps: hasAppAccess, getTierLevel, getAccessibleManaApps (16 tests)

Also fixes iOS detection bug in userAgent parser (iPhone UA contains
"Mac OS X" — mobile check must come before desktop OS check).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-31 16:35:16 +02:00
parent ed9672ef2b
commit 4fb851947e
9 changed files with 555 additions and 5 deletions

View file

@ -18,7 +18,9 @@
"src"
],
"scripts": {
"lint": "eslint ."
"lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest"
},
"peerDependencies": {
"@manacore/shared-auth": "workspace:*",

View file

@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
shouldShowGuestWelcome,
markGuestWelcomeSeen,
resetGuestWelcome,
resetAllGuestWelcome,
} from './guestWelcome';
// Mock localStorage
const store: Record<string, string> = {};
const localStorageMock = {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete store[key];
}),
get length() {
return Object.keys(store).length;
},
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
clear: vi.fn(() => {
for (const key of Object.keys(store)) delete store[key];
}),
};
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock });
beforeEach(() => {
for (const key of Object.keys(store)) delete store[key];
vi.clearAllMocks();
});
describe('shouldShowGuestWelcome', () => {
it('returns true when not seen before', () => {
expect(shouldShowGuestWelcome('todo')).toBe(true);
});
it('returns false after marking as seen', () => {
markGuestWelcomeSeen('todo');
expect(shouldShowGuestWelcome('todo')).toBe(false);
});
it('scopes to app ID', () => {
markGuestWelcomeSeen('todo');
expect(shouldShowGuestWelcome('todo')).toBe(false);
expect(shouldShowGuestWelcome('chat')).toBe(true);
});
});
describe('markGuestWelcomeSeen', () => {
it('writes to localStorage with correct key', () => {
markGuestWelcomeSeen('contacts');
expect(localStorageMock.setItem).toHaveBeenCalledWith('guest-welcome-seen-contacts', 'true');
});
});
describe('resetGuestWelcome', () => {
it('removes the key for a specific app', () => {
markGuestWelcomeSeen('todo');
expect(shouldShowGuestWelcome('todo')).toBe(false);
resetGuestWelcome('todo');
expect(shouldShowGuestWelcome('todo')).toBe(true);
});
it('does not affect other apps', () => {
markGuestWelcomeSeen('todo');
markGuestWelcomeSeen('chat');
resetGuestWelcome('todo');
expect(shouldShowGuestWelcome('chat')).toBe(false);
});
});
describe('resetAllGuestWelcome', () => {
it('removes all guest-welcome keys', () => {
markGuestWelcomeSeen('todo');
markGuestWelcomeSeen('chat');
markGuestWelcomeSeen('calendar');
resetAllGuestWelcome();
expect(shouldShowGuestWelcome('todo')).toBe(true);
expect(shouldShowGuestWelcome('chat')).toBe(true);
expect(shouldShowGuestWelcome('calendar')).toBe(true);
});
it('does not remove unrelated keys', () => {
store['other-key'] = 'value';
markGuestWelcomeSeen('todo');
resetAllGuestWelcome();
expect(store['other-key']).toBe('value');
});
});

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest';
import { parseUserAgent, getDeviceType, formatUserAgent } from './userAgent';
describe('parseUserAgent', () => {
it('returns empty strings for null', () => {
expect(parseUserAgent(null)).toEqual({ browser: '', os: '' });
});
it('returns empty strings for empty string', () => {
expect(parseUserAgent('')).toEqual({ browser: '', os: '' });
});
it('detects Chrome on macOS', () => {
const ua =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
expect(parseUserAgent(ua)).toEqual({ browser: 'Chrome', os: 'macOS' });
});
it('detects Firefox on Windows', () => {
const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0';
expect(parseUserAgent(ua)).toEqual({ browser: 'Firefox', os: 'Windows' });
});
it('detects Safari on macOS', () => {
const ua =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15';
expect(parseUserAgent(ua)).toEqual({ browser: 'Safari', os: 'macOS' });
});
it('detects Edge on Windows', () => {
const ua =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0';
expect(parseUserAgent(ua)).toEqual({ browser: 'Edge', os: 'Windows' });
});
it('detects Chrome on Android', () => {
const ua =
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
expect(parseUserAgent(ua)).toEqual({ browser: 'Chrome', os: 'Android' });
});
it('detects Safari on iOS', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
expect(parseUserAgent(ua)).toEqual({ browser: 'Safari', os: 'iOS' });
});
it('detects Chrome on Linux', () => {
const ua =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
expect(parseUserAgent(ua)).toEqual({ browser: 'Chrome', os: 'Linux' });
});
});
describe('getDeviceType', () => {
it('returns desktop for null', () => {
expect(getDeviceType(null)).toBe('desktop');
});
it('returns mobile for iPhone', () => {
const ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)';
expect(getDeviceType(ua)).toBe('mobile');
});
it('returns mobile for Android phone', () => {
const ua = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile Safari/537.36';
expect(getDeviceType(ua)).toBe('mobile');
});
it('returns tablet for iPad', () => {
const ua = 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)';
expect(getDeviceType(ua)).toBe('tablet');
});
it('returns desktop for macOS Chrome', () => {
const ua =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0';
expect(getDeviceType(ua)).toBe('desktop');
});
});
describe('formatUserAgent', () => {
it('returns empty string for null', () => {
expect(formatUserAgent(null)).toBe('');
});
it('formats browser and OS with separator', () => {
const ua =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36';
expect(formatUserAgent(ua)).toBe('Chrome \u00b7 macOS');
});
it('formats browser only if OS unknown', () => {
const ua = 'Mozilla/5.0 Chrome/120.0.0.0';
expect(formatUserAgent(ua)).toBe('Chrome');
});
});

View file

@ -13,11 +13,11 @@ export function parseUserAgent(ua: string | null): { browser: string; os: string
else if (ua.includes('Safari/') && !ua.includes('Chrome/')) browser = 'Safari';
else if (ua.includes('Opera/') || ua.includes('OPR/')) browser = 'Opera';
if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) os = 'macOS';
else if (ua.includes('Linux') && !ua.includes('Android')) os = 'Linux';
if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
else if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) os = 'macOS';
else if (ua.includes('Linux')) os = 'Linux';
return { browser, os };
}

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.{test,spec}.ts'],
environment: 'node',
globals: true,
clearMocks: true,
mockReset: true,
restoreMocks: true,
},
});

View file

@ -12,6 +12,8 @@
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
"lint": "eslint ."
},

View file

@ -0,0 +1,209 @@
import { describe, it, expect } from 'vitest';
import {
decodeToken,
isTokenValidLocally,
isTokenExpired,
getUserFromToken,
getTokenExpirationTime,
getTimeUntilExpiration,
isB2BUser,
getB2BInfo,
} from './jwtUtils';
// Helper: create a fake JWT with given payload
function createToken(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'EdDSA', typ: 'JWT' }));
const body = btoa(JSON.stringify(payload));
return `${header}.${body}.fakesignature`;
}
describe('decodeToken', () => {
it('decodes a valid JWT payload', () => {
const token = createToken({ sub: 'user-1', email: 'test@example.com', exp: 9999999999 });
const decoded = decodeToken(token);
expect(decoded).toMatchObject({ sub: 'user-1', email: 'test@example.com' });
});
it('returns null for invalid token', () => {
expect(decodeToken('not-a-jwt')).toBeNull();
});
it('returns null for empty string', () => {
expect(decodeToken('')).toBeNull();
});
it('returns null for malformed base64', () => {
expect(decodeToken('a.!!!invalid!!!.c')).toBeNull();
});
});
describe('isTokenValidLocally', () => {
it('returns true for non-expired token', () => {
const futureExp = Math.floor(Date.now() / 1000) + 3600;
const token = createToken({ sub: 'u', exp: futureExp });
expect(isTokenValidLocally(token)).toBe(true);
});
it('returns false for expired token', () => {
const pastExp = Math.floor(Date.now() / 1000) - 60;
const token = createToken({ sub: 'u', exp: pastExp });
expect(isTokenValidLocally(token)).toBe(false);
});
it('returns false when within buffer', () => {
const almostExpired = Math.floor(Date.now() / 1000) + 5;
const token = createToken({ sub: 'u', exp: almostExpired });
expect(isTokenValidLocally(token, 10)).toBe(false);
});
it('returns false for token without exp', () => {
const token = createToken({ sub: 'u' });
expect(isTokenValidLocally(token)).toBe(false);
});
});
describe('isTokenExpired', () => {
it('returns true for expired token', () => {
const pastExp = Math.floor(Date.now() / 1000) - 60;
const token = createToken({ sub: 'u', exp: pastExp });
expect(isTokenExpired(token)).toBe(true);
});
it('returns false for valid token', () => {
const futureExp = Math.floor(Date.now() / 1000) + 3600;
const token = createToken({ sub: 'u', exp: futureExp });
expect(isTokenExpired(token)).toBe(false);
});
});
describe('getUserFromToken', () => {
it('extracts user data with tier', () => {
const token = createToken({
sub: 'user-123',
email: 'test@mana.how',
role: 'admin',
tier: 'founder',
exp: 9999999999,
});
const user = getUserFromToken(token);
expect(user).toEqual({
id: 'user-123',
email: 'test@mana.how',
role: 'admin',
tier: 'founder',
});
});
it('defaults tier to public when missing', () => {
const token = createToken({ sub: 'u', email: 'a@b.c', exp: 9999999999 });
const user = getUserFromToken(token);
expect(user?.tier).toBe('public');
});
it('defaults role to user when missing', () => {
const token = createToken({ sub: 'u', email: 'a@b.c', exp: 9999999999 });
expect(getUserFromToken(token)?.role).toBe('user');
});
it('falls back to user_metadata.email', () => {
const token = createToken({
sub: 'u',
user_metadata: { email: 'meta@test.com' },
exp: 9999999999,
});
expect(getUserFromToken(token)?.email).toBe('meta@test.com');
});
it('falls back to storedEmail', () => {
const token = createToken({ sub: 'u', exp: 9999999999 });
expect(getUserFromToken(token, 'stored@test.com')?.email).toBe('stored@test.com');
});
it('defaults email to user@example.com when all sources empty', () => {
const token = createToken({ sub: 'u', exp: 9999999999 });
expect(getUserFromToken(token)?.email).toBe('user@example.com');
});
it('returns null for invalid token', () => {
expect(getUserFromToken('garbage')).toBeNull();
});
});
describe('getTokenExpirationTime', () => {
it('returns exp in milliseconds', () => {
const exp = 1700000000;
const token = createToken({ sub: 'u', exp });
expect(getTokenExpirationTime(token)).toBe(exp * 1000);
});
it('returns null without exp', () => {
const token = createToken({ sub: 'u' });
expect(getTokenExpirationTime(token)).toBeNull();
});
});
describe('getTimeUntilExpiration', () => {
it('returns positive ms for future token', () => {
const futureExp = Math.floor(Date.now() / 1000) + 3600;
const token = createToken({ sub: 'u', exp: futureExp });
const remaining = getTimeUntilExpiration(token);
expect(remaining).toBeGreaterThan(3500000);
expect(remaining).toBeLessThanOrEqual(3600000);
});
it('returns 0 for expired token', () => {
const pastExp = Math.floor(Date.now() / 1000) - 60;
const token = createToken({ sub: 'u', exp: pastExp });
expect(getTimeUntilExpiration(token)).toBe(0);
});
});
describe('isB2BUser', () => {
it('returns true for is_b2b: true', () => {
const token = createToken({ sub: 'u', is_b2b: true, exp: 9999999999 });
expect(isB2BUser(token)).toBe(true);
});
it('returns true for is_b2b: "true"', () => {
const token = createToken({ sub: 'u', is_b2b: 'true', exp: 9999999999 });
expect(isB2BUser(token)).toBe(true);
});
it('returns true for is_b2b: 1', () => {
const token = createToken({ sub: 'u', is_b2b: 1, exp: 9999999999 });
expect(isB2BUser(token)).toBe(true);
});
it('returns false when not set', () => {
const token = createToken({ sub: 'u', exp: 9999999999 });
expect(isB2BUser(token)).toBe(false);
});
});
describe('getB2BInfo', () => {
it('extracts B2B settings', () => {
const token = createToken({
sub: 'u',
app_settings: {
b2b: {
disableRevenueCat: true,
organizationId: 'org-1',
plan: 'enterprise',
role: 'admin',
},
},
exp: 9999999999,
});
expect(getB2BInfo(token)).toEqual({
disableRevenueCat: true,
organizationId: 'org-1',
plan: 'enterprise',
role: 'admin',
});
});
it('returns null when no B2B settings', () => {
const token = createToken({ sub: 'u', exp: 9999999999 });
expect(getB2BInfo(token)).toBeNull();
});
});

View file

@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.{test,spec}.ts'],
environment: 'node',
globals: true,
clearMocks: true,
mockReset: true,
restoreMocks: true,
},
});

View file

@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import {
hasAppAccess,
getTierLevel,
getAccessibleManaApps,
getActiveManaApps,
getManaApp,
getManaAppsByStatus,
MANA_APPS,
} from './mana-apps';
describe('getTierLevel', () => {
it('returns correct levels for all tiers', () => {
expect(getTierLevel('guest')).toBe(0);
expect(getTierLevel('public')).toBe(1);
expect(getTierLevel('beta')).toBe(2);
expect(getTierLevel('alpha')).toBe(3);
expect(getTierLevel('founder')).toBe(4);
});
it('returns 0 for unknown tier', () => {
expect(getTierLevel('unknown')).toBe(0);
expect(getTierLevel('')).toBe(0);
});
});
describe('hasAppAccess', () => {
it('founder can access everything', () => {
expect(hasAppAccess('founder', 'guest')).toBe(true);
expect(hasAppAccess('founder', 'public')).toBe(true);
expect(hasAppAccess('founder', 'beta')).toBe(true);
expect(hasAppAccess('founder', 'alpha')).toBe(true);
expect(hasAppAccess('founder', 'founder')).toBe(true);
});
it('public cannot access beta+', () => {
expect(hasAppAccess('public', 'public')).toBe(true);
expect(hasAppAccess('public', 'beta')).toBe(false);
expect(hasAppAccess('public', 'alpha')).toBe(false);
expect(hasAppAccess('public', 'founder')).toBe(false);
});
it('beta can access beta and below', () => {
expect(hasAppAccess('beta', 'guest')).toBe(true);
expect(hasAppAccess('beta', 'public')).toBe(true);
expect(hasAppAccess('beta', 'beta')).toBe(true);
expect(hasAppAccess('beta', 'alpha')).toBe(false);
expect(hasAppAccess('beta', 'founder')).toBe(false);
});
it('guest can only access guest tier', () => {
expect(hasAppAccess('guest', 'guest')).toBe(true);
expect(hasAppAccess('guest', 'public')).toBe(false);
});
it('unknown tier treated as guest (level 0)', () => {
expect(hasAppAccess('unknown', 'guest')).toBe(true);
expect(hasAppAccess('unknown', 'public')).toBe(false);
});
});
describe('getAccessibleManaApps', () => {
it('founder sees all non-archived apps', () => {
const founderApps = getAccessibleManaApps('founder');
const activeApps = getActiveManaApps();
expect(founderApps.length).toBe(activeApps.length);
});
it('public sees fewer apps than founder', () => {
const publicApps = getAccessibleManaApps('public');
const founderApps = getAccessibleManaApps('founder');
expect(publicApps.length).toBeLessThanOrEqual(founderApps.length);
});
it('guest sees no apps (all require at least public)', () => {
const guestApps = getAccessibleManaApps('guest');
// All apps require at least 'public' tier
expect(guestApps.length).toBe(0);
});
it('excludes archived apps', () => {
const apps = getAccessibleManaApps('founder');
const archivedApps = apps.filter((a) => a.archived);
expect(archivedApps.length).toBe(0);
});
});
describe('getManaApp', () => {
it('returns app by ID', () => {
const todo = getManaApp('todo');
expect(todo).toBeDefined();
expect(todo?.name).toBe('Todo');
});
it('returns undefined for unknown ID', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(getManaApp('nonexistent' as any)).toBeUndefined();
});
});
describe('getManaAppsByStatus', () => {
it('filters by status', () => {
const betaApps = getManaAppsByStatus('beta');
betaApps.forEach((app) => {
expect(app.status).toBe('beta');
});
});
});
describe('MANA_APPS integrity', () => {
it('every app has a requiredTier', () => {
MANA_APPS.forEach((app) => {
expect(app.requiredTier).toBeDefined();
expect(['guest', 'public', 'beta', 'alpha', 'founder']).toContain(app.requiredTier);
});
});
it('every app has bilingual descriptions', () => {
MANA_APPS.forEach((app) => {
expect(app.description.de).toBeTruthy();
expect(app.description.en).toBeTruthy();
});
});
});