Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
This commit is contained in:
975
apps/server/test/integration/mobile.integration.test.ts
Normal file
975
apps/server/test/integration/mobile.integration.test.ts
Normal file
@@ -0,0 +1,975 @@
|
||||
/**
|
||||
* Mobile Authentication Integration Tests
|
||||
*
|
||||
* Tests mobile pairing, token exchange, refresh, and session management
|
||||
* against a real database.
|
||||
*
|
||||
* Run with: pnpm test:integration
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import jwt from '@fastify/jwt';
|
||||
import cookie from '@fastify/cookie';
|
||||
import sensible from '@fastify/sensible';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Redis } from 'ioredis';
|
||||
import type { AuthUser } from '@tracearr/shared';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { users, servers, serverUsers, settings, mobileTokens, mobileSessions } from '../../src/db/schema.js';
|
||||
import { mobileRoutes } from '../../src/routes/mobile.js';
|
||||
|
||||
// Constants (matching mobile.ts)
|
||||
const TOKEN_EXPIRY_MINUTES = 15;
|
||||
const MOBILE_TOKEN_PREFIX = 'trr_mob_';
|
||||
const MOBILE_REFRESH_TTL = 90 * 24 * 60 * 60; // 90 days
|
||||
|
||||
// Test helpers
|
||||
function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
function generateTestMobileToken(): string {
|
||||
const randomPart = randomBytes(32).toString('base64url');
|
||||
return `${MOBILE_TOKEN_PREFIX}${randomPart}`;
|
||||
}
|
||||
|
||||
// Create mock Redis for rate limiting
|
||||
function createMockRedis() {
|
||||
const store = new Map<string, string>();
|
||||
const counters = new Map<string, number>();
|
||||
|
||||
return {
|
||||
get: vi.fn(async (key: string) => store.get(key) ?? null),
|
||||
set: vi.fn(async (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
}),
|
||||
setex: vi.fn(async (key: string, _seconds: number, value: string) => {
|
||||
store.set(key, value);
|
||||
return 'OK';
|
||||
}),
|
||||
del: vi.fn(async (key: string) => {
|
||||
store.delete(key);
|
||||
return 1;
|
||||
}),
|
||||
incr: vi.fn(async (key: string) => {
|
||||
const current = counters.get(key) ?? 0;
|
||||
counters.set(key, current + 1);
|
||||
return current + 1;
|
||||
}),
|
||||
expire: vi.fn(async () => 1),
|
||||
ttl: vi.fn(async () => 300),
|
||||
ping: vi.fn(async () => 'PONG'),
|
||||
keys: vi.fn(async (pattern: string) => {
|
||||
const prefix = pattern.replace('*', '');
|
||||
return Array.from(store.keys()).filter(k => k.startsWith(prefix));
|
||||
}),
|
||||
eval: vi.fn(async () => 1), // Default: first attempt (not rate limited)
|
||||
_store: store,
|
||||
_counters: counters,
|
||||
_reset: () => {
|
||||
store.clear();
|
||||
counters.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Test data holder
|
||||
interface TestData {
|
||||
ownerId: string;
|
||||
serverId: string;
|
||||
serverUserId: string;
|
||||
}
|
||||
|
||||
// Create test Fastify app with mobile routes
|
||||
async function createMobileTestApp(): Promise<FastifyInstance> {
|
||||
const app = Fastify({
|
||||
logger: false,
|
||||
});
|
||||
|
||||
// Register essential plugins
|
||||
await app.register(sensible);
|
||||
await app.register(cookie, { secret: 'test-cookie-secret-32-chars-long!' });
|
||||
await app.register(jwt, {
|
||||
secret: process.env.JWT_SECRET ?? 'test-jwt-secret-must-be-32-chars-min',
|
||||
sign: { algorithm: 'HS256' },
|
||||
});
|
||||
|
||||
// Add mock Redis
|
||||
const mockRedis = createMockRedis();
|
||||
app.decorate('redis', mockRedis as unknown as Redis);
|
||||
|
||||
// Add authenticate decorator
|
||||
app.decorate('authenticate', async function (request: any, reply: any) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
reply.unauthorized('Invalid or expired token');
|
||||
}
|
||||
});
|
||||
|
||||
// Add requireOwner decorator
|
||||
app.decorate('requireOwner', async function (request: any, reply: any) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
if (request.user.role !== 'owner') {
|
||||
reply.forbidden('Owner access required');
|
||||
}
|
||||
} catch {
|
||||
reply.unauthorized('Invalid or expired token');
|
||||
}
|
||||
});
|
||||
|
||||
// Add requireMobile decorator - validates token was issued for mobile app
|
||||
app.decorate('requireMobile', async function (request: any, reply: any) {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
if (!request.user.mobile) {
|
||||
reply.forbidden('Mobile access token required');
|
||||
}
|
||||
} catch {
|
||||
reply.unauthorized('Invalid or expired token');
|
||||
}
|
||||
});
|
||||
|
||||
// Register mobile routes (same prefix as server)
|
||||
await app.register(mobileRoutes, { prefix: '/api/v1/mobile' });
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Seed test data
|
||||
async function seedTestData(): Promise<TestData> {
|
||||
// Create owner user
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
username: 'testowner',
|
||||
name: 'Test Owner',
|
||||
role: 'owner',
|
||||
aggregateTrustScore: 100,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create server
|
||||
const [server] = await db
|
||||
.insert(servers)
|
||||
.values({
|
||||
name: 'Test Plex Server',
|
||||
type: 'plex',
|
||||
url: 'http://localhost:32400',
|
||||
token: 'test-token-encrypted',
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create server_user
|
||||
const [serverUser] = await db
|
||||
.insert(serverUsers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
serverId: server.id,
|
||||
externalId: 'plex-user-1',
|
||||
username: 'testowner',
|
||||
isServerAdmin: true,
|
||||
trustScore: 100,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Ensure settings row exists with mobile enabled
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ id: 1, mobileEnabled: true })
|
||||
.onConflictDoUpdate({
|
||||
target: settings.id,
|
||||
set: { mobileEnabled: true },
|
||||
});
|
||||
|
||||
return {
|
||||
ownerId: user.id,
|
||||
serverId: server.id,
|
||||
serverUserId: serverUser.id,
|
||||
};
|
||||
}
|
||||
|
||||
// Clean up test data
|
||||
async function cleanupTestData(): Promise<void> {
|
||||
// Clean in reverse order of dependencies
|
||||
await db.delete(mobileSessions);
|
||||
await db.delete(mobileTokens);
|
||||
await db.delete(serverUsers);
|
||||
await db.delete(servers);
|
||||
await db.delete(users);
|
||||
}
|
||||
|
||||
// Generate owner JWT token
|
||||
function generateOwnerToken(app: FastifyInstance, testData: TestData): string {
|
||||
return app.jwt.sign(
|
||||
{
|
||||
userId: testData.ownerId,
|
||||
username: 'testowner',
|
||||
role: 'owner',
|
||||
serverIds: [testData.serverId],
|
||||
} as AuthUser,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
}
|
||||
|
||||
describe('Mobile Authentication Integration Tests', () => {
|
||||
let app: FastifyInstance;
|
||||
let testData: TestData;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createMobileTestApp();
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestData();
|
||||
testData = await seedTestData();
|
||||
// Reset mock Redis
|
||||
(app.redis as any)._reset();
|
||||
});
|
||||
|
||||
describe('POST /api/v1/mobile/pair-token - Generate Pairing Token', () => {
|
||||
it('should generate a valid pairing token for owner', async () => {
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair-token',
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.token).toBeDefined();
|
||||
expect(body.token).toMatch(/^trr_mob_/);
|
||||
expect(body.expiresAt).toBeDefined();
|
||||
|
||||
// Verify token was stored in database
|
||||
const tokenHash = hashToken(body.token);
|
||||
const [storedToken] = await db
|
||||
.select()
|
||||
.from(mobileTokens)
|
||||
.where(eq(mobileTokens.tokenHash, tokenHash));
|
||||
|
||||
expect(storedToken).toBeDefined();
|
||||
expect(storedToken.createdBy).toBe(testData.ownerId);
|
||||
expect(storedToken.usedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should reject non-owner users', async () => {
|
||||
// Create a viewer token
|
||||
const viewerToken = app.jwt.sign(
|
||||
{
|
||||
userId: testData.ownerId,
|
||||
username: 'testviewer',
|
||||
role: 'viewer',
|
||||
serverIds: [],
|
||||
} as AuthUser,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair-token',
|
||||
headers: { Authorization: `Bearer ${viewerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should reject unauthenticated requests', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair-token',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject when mobile is disabled', async () => {
|
||||
// Disable mobile
|
||||
await db.update(settings).set({ mobileEnabled: false }).where(eq(settings.id, 1));
|
||||
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair-token',
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().message).toContain('Mobile access is not enabled');
|
||||
});
|
||||
|
||||
it('should not count expired tokens toward pending limit', async () => {
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
// Insert an expired token - should not count toward limit
|
||||
await db.insert(mobileTokens).values({
|
||||
tokenHash: 'expired-token-hash-1234567890abcdef1234567890abcdef',
|
||||
expiresAt: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago
|
||||
createdBy: testData.ownerId,
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair-token',
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Both tokens exist (expired tokens are not automatically cleaned up)
|
||||
const tokens = await db.select().from(mobileTokens);
|
||||
expect(tokens.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should enforce max pending tokens limit', async () => {
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
// Create 3 pending tokens (MAX_PENDING_TOKENS)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await db.insert(mobileTokens).values({
|
||||
tokenHash: `pending-token-hash-${i}-abcdef1234567890abcdef1234567890`,
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 15), // 15 min from now
|
||||
createdBy: testData.ownerId,
|
||||
});
|
||||
}
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair-token',
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().message).toContain('Maximum of 3 pending tokens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/mobile/pair - Exchange Token for JWT', () => {
|
||||
let validPairingToken: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Generate a valid pairing token in the database
|
||||
validPairingToken = generateTestMobileToken();
|
||||
await db.insert(mobileTokens).values({
|
||||
tokenHash: hashToken(validPairingToken),
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * TOKEN_EXPIRY_MINUTES),
|
||||
createdBy: testData.ownerId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should exchange valid token for JWT and refresh token', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair',
|
||||
payload: {
|
||||
token: validPairingToken,
|
||||
deviceName: 'Test iPhone',
|
||||
deviceId: 'device-12345',
|
||||
platform: 'ios',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.accessToken).toBeDefined();
|
||||
expect(body.refreshToken).toBeDefined();
|
||||
expect(body.server).toBeDefined();
|
||||
expect(body.user).toBeDefined();
|
||||
expect(body.user.username).toBe('testowner');
|
||||
|
||||
// Verify mobile session was created
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(mobileSessions)
|
||||
.where(eq(mobileSessions.deviceId, 'device-12345'));
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.deviceName).toBe('Test iPhone');
|
||||
expect(session.platform).toBe('ios');
|
||||
|
||||
// Verify pairing token was marked as used
|
||||
const [usedToken] = await db
|
||||
.select()
|
||||
.from(mobileTokens)
|
||||
.where(eq(mobileTokens.tokenHash, hashToken(validPairingToken)));
|
||||
|
||||
expect(usedToken.usedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should reject expired pairing token', async () => {
|
||||
// Create an expired token
|
||||
const expiredToken = generateTestMobileToken();
|
||||
await db.insert(mobileTokens).values({
|
||||
tokenHash: hashToken(expiredToken),
|
||||
expiresAt: new Date(Date.now() - 1000 * 60), // 1 minute ago
|
||||
createdBy: testData.ownerId,
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair',
|
||||
payload: {
|
||||
token: expiredToken,
|
||||
deviceName: 'Test iPhone',
|
||||
deviceId: 'device-12345',
|
||||
platform: 'ios',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().message).toContain('expired');
|
||||
});
|
||||
|
||||
it('should reject already-used pairing token', async () => {
|
||||
// First, use the token
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair',
|
||||
payload: {
|
||||
token: validPairingToken,
|
||||
deviceName: 'First Device',
|
||||
deviceId: 'device-first',
|
||||
platform: 'ios',
|
||||
},
|
||||
});
|
||||
|
||||
// Try to use it again
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair',
|
||||
payload: {
|
||||
token: validPairingToken,
|
||||
deviceName: 'Second Device',
|
||||
deviceId: 'device-second',
|
||||
platform: 'android',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().message).toContain('already been used');
|
||||
});
|
||||
|
||||
it('should reject invalid token format', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair',
|
||||
payload: {
|
||||
token: 'invalid-token',
|
||||
deviceName: 'Test iPhone',
|
||||
deviceId: 'device-12345',
|
||||
platform: 'ios',
|
||||
},
|
||||
});
|
||||
|
||||
// Token without correct prefix returns 401 unauthorized
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should allow pairing even when mobile is disabled (token was pre-generated)', async () => {
|
||||
// Note: /pair doesn't check mobileEnabled - tokens can be used if they were
|
||||
// generated before mobile was disabled. Disabling mobile only prevents NEW tokens.
|
||||
await db.update(settings).set({ mobileEnabled: false }).where(eq(settings.id, 1));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair',
|
||||
payload: {
|
||||
token: validPairingToken,
|
||||
deviceName: 'Test iPhone',
|
||||
deviceId: 'device-12345',
|
||||
platform: 'ios',
|
||||
},
|
||||
});
|
||||
|
||||
// Pairing succeeds because the token was valid
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().accessToken).toBeDefined();
|
||||
});
|
||||
|
||||
it('should enforce max paired devices limit', async () => {
|
||||
// Create 5 existing sessions (MAX_PAIRED_DEVICES)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await db.insert(mobileSessions).values({
|
||||
userId: testData.ownerId,
|
||||
refreshTokenHash: `existing-refresh-hash-${i}-abcdef1234567890abc`,
|
||||
deviceName: `Existing Device ${i}`,
|
||||
deviceId: `existing-device-${i}`,
|
||||
platform: 'ios',
|
||||
});
|
||||
}
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair',
|
||||
payload: {
|
||||
token: validPairingToken,
|
||||
deviceName: 'New Device',
|
||||
deviceId: 'device-new',
|
||||
platform: 'android',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().message).toContain('Maximum of 5 devices');
|
||||
});
|
||||
|
||||
it('should accept device secret for push encryption', async () => {
|
||||
const deviceSecret = randomBytes(32).toString('base64');
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair',
|
||||
payload: {
|
||||
token: validPairingToken,
|
||||
deviceName: 'Test iPhone',
|
||||
deviceId: 'device-12345',
|
||||
platform: 'ios',
|
||||
deviceSecret,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// Verify device secret was stored
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(mobileSessions)
|
||||
.where(eq(mobileSessions.deviceId, 'device-12345'));
|
||||
|
||||
expect(session.deviceSecret).toBe(deviceSecret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/mobile/refresh - Refresh JWT Token', () => {
|
||||
let validRefreshToken: string;
|
||||
let mobileJwt: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Generate a pairing token and pair a device
|
||||
const pairingToken = generateTestMobileToken();
|
||||
await db.insert(mobileTokens).values({
|
||||
tokenHash: hashToken(pairingToken),
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * TOKEN_EXPIRY_MINUTES),
|
||||
createdBy: testData.ownerId,
|
||||
});
|
||||
|
||||
const pairRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/pair',
|
||||
payload: {
|
||||
token: pairingToken,
|
||||
deviceName: 'Test Device',
|
||||
deviceId: 'device-refresh-test',
|
||||
platform: 'ios',
|
||||
},
|
||||
});
|
||||
|
||||
const pairBody = pairRes.json();
|
||||
validRefreshToken = pairBody.refreshToken;
|
||||
mobileJwt = pairBody.accessToken;
|
||||
});
|
||||
|
||||
it('should refresh token and rotate refresh token', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/refresh',
|
||||
payload: {
|
||||
refreshToken: validRefreshToken,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.accessToken).toBeDefined();
|
||||
expect(body.refreshToken).toBeDefined();
|
||||
|
||||
// New refresh token should be different (rotation)
|
||||
expect(body.refreshToken).not.toBe(validRefreshToken);
|
||||
|
||||
// Old refresh token should no longer work
|
||||
const secondRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/refresh',
|
||||
payload: {
|
||||
refreshToken: validRefreshToken,
|
||||
},
|
||||
});
|
||||
|
||||
expect(secondRes.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('should reject invalid refresh token', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/refresh',
|
||||
payload: {
|
||||
refreshToken: 'invalid-refresh-token',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().message).toContain('Invalid or expired refresh token');
|
||||
});
|
||||
|
||||
it('should allow refresh even when mobile is disabled', async () => {
|
||||
// Note: /refresh doesn't check mobileEnabled - existing sessions continue working.
|
||||
// Disabling mobile only prevents NEW tokens and revokes sessions when explicitly disabled.
|
||||
await db.update(settings).set({ mobileEnabled: false }).where(eq(settings.id, 1));
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/refresh',
|
||||
payload: {
|
||||
refreshToken: validRefreshToken,
|
||||
},
|
||||
});
|
||||
|
||||
// Refresh succeeds because the session is still valid
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().accessToken).toBeDefined();
|
||||
});
|
||||
|
||||
it('should update lastSeenAt on refresh', async () => {
|
||||
// Get the session before refresh
|
||||
const [sessionBefore] = await db
|
||||
.select()
|
||||
.from(mobileSessions)
|
||||
.where(eq(mobileSessions.deviceId, 'device-refresh-test'));
|
||||
|
||||
const lastSeenBefore = sessionBefore.lastSeenAt;
|
||||
|
||||
// Wait a bit to ensure timestamp difference
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/refresh',
|
||||
payload: {
|
||||
refreshToken: validRefreshToken,
|
||||
},
|
||||
});
|
||||
|
||||
// Get the session after refresh
|
||||
const [sessionAfter] = await db
|
||||
.select()
|
||||
.from(mobileSessions)
|
||||
.where(eq(mobileSessions.deviceId, 'device-refresh-test'));
|
||||
|
||||
expect(sessionAfter.lastSeenAt.getTime()).toBeGreaterThan(lastSeenBefore.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/mobile/sessions/:id - Revoke Session', () => {
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a mobile session
|
||||
const [session] = await db
|
||||
.insert(mobileSessions)
|
||||
.values({
|
||||
userId: testData.ownerId,
|
||||
refreshTokenHash: 'test-refresh-hash-for-deletion-1234567890abcdef',
|
||||
deviceName: 'Device to Delete',
|
||||
deviceId: 'device-to-delete',
|
||||
platform: 'ios',
|
||||
})
|
||||
.returning();
|
||||
|
||||
sessionId = session.id;
|
||||
});
|
||||
|
||||
it('should revoke session as owner', async () => {
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/mobile/sessions/${sessionId}`,
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().success).toBe(true);
|
||||
|
||||
// Verify session was deleted
|
||||
const sessions = await db
|
||||
.select()
|
||||
.from(mobileSessions)
|
||||
.where(eq(mobileSessions.id, sessionId));
|
||||
|
||||
expect(sessions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should reject non-owner users', async () => {
|
||||
const viewerToken = app.jwt.sign(
|
||||
{
|
||||
userId: testData.ownerId,
|
||||
username: 'testviewer',
|
||||
role: 'viewer',
|
||||
serverIds: [],
|
||||
} as AuthUser,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/mobile/sessions/${sessionId}`,
|
||||
headers: { Authorization: `Bearer ${viewerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent session', async () => {
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/mobile/sessions/00000000-0000-0000-0000-000000000000',
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('should reject unauthenticated requests', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/mobile/sessions/${sessionId}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/v1/mobile/sessions - Revoke All Sessions', () => {
|
||||
beforeEach(async () => {
|
||||
// Create multiple mobile sessions
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await db.insert(mobileSessions).values({
|
||||
userId: testData.ownerId,
|
||||
refreshTokenHash: `bulk-delete-refresh-hash-${i}-abcdef1234567890`,
|
||||
deviceName: `Device ${i}`,
|
||||
deviceId: `device-bulk-${i}`,
|
||||
platform: i % 2 === 0 ? 'ios' : 'android',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should revoke all sessions as owner', async () => {
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/mobile/sessions',
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.revokedCount).toBe(3);
|
||||
|
||||
// Verify all sessions were deleted
|
||||
const sessions = await db.select().from(mobileSessions);
|
||||
expect(sessions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return success even with no sessions to revoke', async () => {
|
||||
// Clean up all sessions first
|
||||
await db.delete(mobileSessions);
|
||||
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/mobile/sessions',
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().revokedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should reject non-owner users', async () => {
|
||||
const viewerToken = app.jwt.sign(
|
||||
{
|
||||
userId: testData.ownerId,
|
||||
username: 'testviewer',
|
||||
role: 'viewer',
|
||||
serverIds: [],
|
||||
} as AuthUser,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/v1/mobile/sessions',
|
||||
headers: { Authorization: `Bearer ${viewerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/v1/mobile - Get Mobile Config', () => {
|
||||
it('should return mobile config for owner', async () => {
|
||||
// Create a session to verify it appears in config
|
||||
await db.insert(mobileSessions).values({
|
||||
userId: testData.ownerId,
|
||||
refreshTokenHash: 'config-test-refresh-hash-abcdef1234567890abcd',
|
||||
deviceName: 'Config Test Device',
|
||||
deviceId: 'device-config-test',
|
||||
platform: 'ios',
|
||||
expoPushToken: 'ExponentPushToken[test123]',
|
||||
});
|
||||
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/mobile',
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.isEnabled).toBe(true);
|
||||
expect(body.sessions).toBeDefined();
|
||||
expect(body.sessions.length).toBe(1);
|
||||
expect(body.sessions[0].deviceName).toBe('Config Test Device');
|
||||
expect(body.sessions[0].platform).toBe('ios');
|
||||
expect(body.serverName).toBeDefined();
|
||||
expect(body.maxDevices).toBe(5);
|
||||
});
|
||||
|
||||
it('should reject non-owner users', async () => {
|
||||
const viewerToken = app.jwt.sign(
|
||||
{
|
||||
userId: testData.ownerId,
|
||||
username: 'testviewer',
|
||||
role: 'viewer',
|
||||
serverIds: [],
|
||||
} as AuthUser,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/v1/mobile',
|
||||
headers: { Authorization: `Bearer ${viewerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/mobile/enable - Enable Mobile Access', () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure mobile is disabled
|
||||
await db.update(settings).set({ mobileEnabled: false }).where(eq(settings.id, 1));
|
||||
});
|
||||
|
||||
it('should enable mobile access as owner', async () => {
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/enable',
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().isEnabled).toBe(true);
|
||||
|
||||
// Verify in database
|
||||
const [settingsRow] = await db.select().from(settings).where(eq(settings.id, 1));
|
||||
expect(settingsRow.mobileEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject non-owner users', async () => {
|
||||
const viewerToken = app.jwt.sign(
|
||||
{
|
||||
userId: testData.ownerId,
|
||||
username: 'testviewer',
|
||||
role: 'viewer',
|
||||
serverIds: [],
|
||||
} as AuthUser,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/enable',
|
||||
headers: { Authorization: `Bearer ${viewerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/mobile/disable - Disable Mobile Access', () => {
|
||||
it('should disable mobile access and revoke all sessions', async () => {
|
||||
// Create some sessions
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await db.insert(mobileSessions).values({
|
||||
userId: testData.ownerId,
|
||||
refreshTokenHash: `disable-test-refresh-hash-${i}-abcdef1234567`,
|
||||
deviceName: `Device ${i}`,
|
||||
deviceId: `device-disable-${i}`,
|
||||
platform: 'ios',
|
||||
});
|
||||
}
|
||||
|
||||
const ownerToken = generateOwnerToken(app, testData);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/disable',
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().success).toBe(true);
|
||||
|
||||
// Verify mobile is disabled
|
||||
const [settingsRow] = await db.select().from(settings).where(eq(settings.id, 1));
|
||||
expect(settingsRow.mobileEnabled).toBe(false);
|
||||
|
||||
// Verify all sessions were revoked
|
||||
const sessions = await db.select().from(mobileSessions);
|
||||
expect(sessions.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should reject non-owner users', async () => {
|
||||
const viewerToken = app.jwt.sign(
|
||||
{
|
||||
userId: testData.ownerId,
|
||||
username: 'testviewer',
|
||||
role: 'viewer',
|
||||
serverIds: [],
|
||||
} as AuthUser,
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/mobile/disable',
|
||||
headers: { Authorization: `Bearer ${viewerToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
});
|
||||
196
apps/server/test/integration/userService.integration.test.ts
Normal file
196
apps/server/test/integration/userService.integration.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* User Service Integration Tests
|
||||
*
|
||||
* Tests userService functions against a real database.
|
||||
* Uses global integration test setup for database management.
|
||||
*
|
||||
* Run with: pnpm test:integration
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { servers } from '../../src/db/schema.js';
|
||||
import {
|
||||
batchSyncUsersFromMediaServer,
|
||||
syncUserFromMediaServer,
|
||||
getServerUsersByServer,
|
||||
getServerUserByExternalId,
|
||||
} from '../../src/services/userService.js';
|
||||
import type { MediaUser } from '../../src/services/userService.js';
|
||||
|
||||
describe('userService integration tests', () => {
|
||||
let testServerId: string;
|
||||
|
||||
// Create a fresh server before each test (after global reset)
|
||||
beforeEach(async () => {
|
||||
const [server] = await db
|
||||
.insert(servers)
|
||||
.values({
|
||||
name: 'Integration Test Server',
|
||||
type: 'plex',
|
||||
url: 'http://localhost:32400',
|
||||
token: 'encrypted-test-token',
|
||||
})
|
||||
.returning();
|
||||
|
||||
testServerId = server.id;
|
||||
});
|
||||
|
||||
describe('syncUserFromMediaServer', () => {
|
||||
it('should create a new user and server user when none exists', async () => {
|
||||
const mediaUser: MediaUser = {
|
||||
id: `ext-${randomUUID().slice(0, 8)}`,
|
||||
username: 'newuser',
|
||||
email: 'newuser@example.com',
|
||||
thumb: 'https://example.com/thumb.jpg',
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
const result = await syncUserFromMediaServer(testServerId, mediaUser);
|
||||
|
||||
expect(result.created).toBe(true);
|
||||
expect(result.serverUser.externalId).toBe(mediaUser.id);
|
||||
expect(result.serverUser.username).toBe(mediaUser.username);
|
||||
expect(result.user.username).toBe(mediaUser.username);
|
||||
|
||||
// Verify in database
|
||||
const dbServerUser = await getServerUserByExternalId(testServerId, mediaUser.id);
|
||||
expect(dbServerUser).not.toBeNull();
|
||||
expect(dbServerUser?.username).toBe(mediaUser.username);
|
||||
});
|
||||
|
||||
it('should update existing server user when already exists', async () => {
|
||||
const externalId = `ext-${randomUUID().slice(0, 8)}`;
|
||||
const mediaUser: MediaUser = {
|
||||
id: externalId,
|
||||
username: 'originalname',
|
||||
isAdmin: false,
|
||||
};
|
||||
|
||||
// First create
|
||||
const createResult = await syncUserFromMediaServer(testServerId, mediaUser);
|
||||
expect(createResult.created).toBe(true);
|
||||
|
||||
// Then update with new username
|
||||
const updatedMediaUser: MediaUser = {
|
||||
id: externalId,
|
||||
username: 'updatedname',
|
||||
email: 'updated@example.com',
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
const updateResult = await syncUserFromMediaServer(testServerId, updatedMediaUser);
|
||||
expect(updateResult.created).toBe(false);
|
||||
expect(updateResult.serverUser.username).toBe('updatedname');
|
||||
});
|
||||
});
|
||||
|
||||
describe('batchSyncUsersFromMediaServer', () => {
|
||||
it('should return zeros for empty input', async () => {
|
||||
const result = await batchSyncUsersFromMediaServer(testServerId, []);
|
||||
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should create multiple new users', async () => {
|
||||
const mediaUsers: MediaUser[] = [
|
||||
{ id: `batch-1-${randomUUID().slice(0, 8)}`, username: 'batchuser1', isAdmin: false },
|
||||
{ id: `batch-2-${randomUUID().slice(0, 8)}`, username: 'batchuser2', isAdmin: false },
|
||||
{ id: `batch-3-${randomUUID().slice(0, 8)}`, username: 'batchuser3', isAdmin: true },
|
||||
];
|
||||
|
||||
const result = await batchSyncUsersFromMediaServer(testServerId, mediaUsers);
|
||||
|
||||
expect(result.added).toBe(3);
|
||||
expect(result.updated).toBe(0);
|
||||
|
||||
// Verify all users exist in database
|
||||
const serverUsersMap = await getServerUsersByServer(testServerId);
|
||||
expect(serverUsersMap.size).toBe(3);
|
||||
expect(serverUsersMap.get(mediaUsers[0].id)?.username).toBe('batchuser1');
|
||||
expect(serverUsersMap.get(mediaUsers[1].id)?.username).toBe('batchuser2');
|
||||
expect(serverUsersMap.get(mediaUsers[2].id)?.username).toBe('batchuser3');
|
||||
});
|
||||
|
||||
it('should handle mix of new and existing users', async () => {
|
||||
// Create one user first
|
||||
const existingExternalId = `existing-${randomUUID().slice(0, 8)}`;
|
||||
await syncUserFromMediaServer(testServerId, {
|
||||
id: existingExternalId,
|
||||
username: 'existinguser',
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
// Now batch sync with mix of existing and new
|
||||
const mediaUsers: MediaUser[] = [
|
||||
{ id: existingExternalId, username: 'existinguser-updated', isAdmin: false },
|
||||
{ id: `new-1-${randomUUID().slice(0, 8)}`, username: 'newuser1', isAdmin: false },
|
||||
{ id: `new-2-${randomUUID().slice(0, 8)}`, username: 'newuser2', isAdmin: false },
|
||||
];
|
||||
|
||||
const result = await batchSyncUsersFromMediaServer(testServerId, mediaUsers);
|
||||
|
||||
expect(result.added).toBe(2);
|
||||
expect(result.updated).toBe(1);
|
||||
|
||||
// Verify the existing user was updated
|
||||
const updatedUser = await getServerUserByExternalId(testServerId, existingExternalId);
|
||||
expect(updatedUser?.username).toBe('existinguser-updated');
|
||||
});
|
||||
|
||||
it('should handle all existing users (update only)', async () => {
|
||||
// Create users first
|
||||
const externalIds = [
|
||||
`preexist-1-${randomUUID().slice(0, 8)}`,
|
||||
`preexist-2-${randomUUID().slice(0, 8)}`,
|
||||
];
|
||||
|
||||
for (const extId of externalIds) {
|
||||
await syncUserFromMediaServer(testServerId, {
|
||||
id: extId,
|
||||
username: `user-${extId.slice(0, 8)}`,
|
||||
isAdmin: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Batch sync with updates only
|
||||
const mediaUsers: MediaUser[] = externalIds.map((extId) => ({
|
||||
id: extId,
|
||||
username: `updated-${extId.slice(0, 8)}`,
|
||||
isAdmin: true,
|
||||
}));
|
||||
|
||||
const result = await batchSyncUsersFromMediaServer(testServerId, mediaUsers);
|
||||
|
||||
expect(result.added).toBe(0);
|
||||
expect(result.updated).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServerUsersByServer', () => {
|
||||
it('should return empty map for server with no users', async () => {
|
||||
const result = await getServerUsersByServer(testServerId);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return map of all server users keyed by externalId', async () => {
|
||||
// Create some users
|
||||
const mediaUsers: MediaUser[] = [
|
||||
{ id: `map-1-${randomUUID().slice(0, 8)}`, username: 'mapuser1', isAdmin: false },
|
||||
{ id: `map-2-${randomUUID().slice(0, 8)}`, username: 'mapuser2', isAdmin: true },
|
||||
];
|
||||
|
||||
await batchSyncUsersFromMediaServer(testServerId, mediaUsers);
|
||||
|
||||
const result = await getServerUsersByServer(testServerId);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.has(mediaUsers[0].id)).toBe(true);
|
||||
expect(result.has(mediaUsers[1].id)).toBe(true);
|
||||
expect(result.get(mediaUsers[0].id)?.username).toBe('mapuser1');
|
||||
expect(result.get(mediaUsers[1].id)?.isServerAdmin).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user