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

This commit is contained in:
2025-12-17 12:32:50 +13:00
commit 3015f48118
471 changed files with 141143 additions and 0 deletions

View 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);
});
});
});