Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/tests/routes/conversations.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const request = require('supertest');
// Adjust these absolute/relative path strings to align with your app server config
const app = require('../../src/app');
const redisClient = require('../../src/config/redis');

describe('GET /conversations - Ciphertext Safe Metadata Isolation Integration Tests', () => {
// Clear the redis cache environment variables before each test runs to avoid cross-pollution
beforeEach(async () => {
if (redisClient && typeof redisClient.flushall === 'function') {
await redisClient.flushall();
}
});

// Safe mock JWT signature to bypass access barriers
const mockAuthToken = 'bearer-mock-jwt-token-string';

test('should return conversation listings matching safe metadata profiles completely empty of message text bodies', async () => {
const response = await request(app)
.get('/api/conversations')
.set('Authorization', `Bearer ${mockAuthToken}`)
.expect(200);

expect(response.body.success).toBe(true);

const conversations = response.body.data;
expect(Array.isArray(conversations)).toBe(true);

// If active seed items exist, cross-examine the structure fields inside lastMessage arrays
if (conversations.length > 0 && conversations[0].lastMessage) {
const lastMsg = conversations[0].lastMessage;

// 1. ACCEPTANCE CRITERIA: Assert ONLY unclassified structural metadata properties exist
expect(lastMsg).toHaveProperty('senderId');
expect(lastMsg).toHaveProperty('senderDeviceId');
expect(lastMsg).toHaveProperty('contentType');
expect(lastMsg).toHaveProperty('sequenceNumber');
expect(lastMsg).toHaveProperty('createdAt');

// 2. PRIVACY SECURE LINE: Assert that content/plaintext/ciphertext values are strictly undefined
expect(lastMsg.body).toBeUndefined();
expect(lastMsg.text).toBeUndefined();
expect(lastMsg.content).toBeUndefined();
expect(lastMsg.ciphertext).toBeUndefined();
expect(lastMsg.preview).toBeUndefined();
}
});

test('should maintain conversation counters and ordering keys via top level metadata blocks', async () => {
const response = await request(app)
.get('/api/conversations')
.set('Authorization', `Bearer ${mockAuthToken}`)
.expect(200);

const conversations = response.body.data;
if (conversations.length > 0) {
// Unread counters must remain accessible on parent layer to preserve badges functionality
expect(conversations[0]).toHaveProperty('id');
expect(conversations[0]).toHaveProperty('unreadCount');
expect(conversations[0]).toHaveProperty('updatedAt');
expect(typeof conversations[0].unreadCount).toBe('number');
}
});
});
75 changes: 75 additions & 0 deletions apps/web/src/app/routes/conversations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Conversations Routing Interface
* Handles retrieval of active user chats sanitized of end-to-end encryption leaks.
*/

const express = require('express');

Check failure on line 6 in apps/web/src/app/routes/conversations.js

View workflow job for this annotation

GitHub Actions / Lint · Build

A `require()` style import is forbidden

Check failure on line 6 in apps/web/src/app/routes/conversations.js

View workflow job for this annotation

GitHub Actions / build

A `require()` style import is forbidden
const router = express.Router();

// Mock imports matching standard patterns. Adjust these paths if your service
// directory is structured differently relative to this routes folder.
const conversationService = require('../services/conversationService');

Check failure on line 11 in apps/web/src/app/routes/conversations.js

View workflow job for this annotation

GitHub Actions / Lint · Build

A `require()` style import is forbidden

Check failure on line 11 in apps/web/src/app/routes/conversations.js

View workflow job for this annotation

GitHub Actions / build

A `require()` style import is forbidden
const cacheService = require('../services/cacheService');

Check failure on line 12 in apps/web/src/app/routes/conversations.js

View workflow job for this annotation

GitHub Actions / Lint · Build

A `require()` style import is forbidden

Check failure on line 12 in apps/web/src/app/routes/conversations.js

View workflow job for this annotation

GitHub Actions / build

A `require()` style import is forbidden

/**
* GET /api/conversations
* Fetches the active profile's conversations list including safe metadata only.
* * Acceptance Criteria Met:
* - No plaintext or ciphertext preview leaves the server configuration.
* - Unread counts + sorting still function cleanly using the allowed metadata fields.
*/
router.get('/', async (req, res, next) => {
try {
// Fallback to a mock or extracted user ID if your auth middleware populates req.userId instead
const userId = req.user?.id || req.userId;

if (!userId) {
return res.status(401).json({
success: false,
error: 'Unauthorized',
message: 'A valid session or bearer token identity is required.'
});
}

// 1. Attempt to resolve active data array from Redis cache layers
let conversations = await cacheService.getConversationList(userId);

if (!conversations) {
// 2. Fallback execution pipeline querying underlying SQL database records on cache miss
const rawConversations = await conversationService.getUserConversations(userId);

// 3. ENFORCE SECURE METADATA ISOLATION:
// Map through results to strip structural content fields ('body', 'text', 'ciphertext', etc.)
conversations = rawConversations.map(conv => {
const safeLastMessage = conv.lastMessage ? {
senderId: conv.lastMessage.senderId,
senderDeviceId: conv.lastMessage.senderDeviceId,
contentType: conv.lastMessage.contentType,
sequenceNumber: conv.lastMessage.sequenceNumber,
createdAt: conv.lastMessage.createdAt
// CRITICAL: Explicitly excluding raw text body, message string payloads, or cipher fragments here.
} : null;

return {
id: conv.id,
participants: conv.participants || [],
unreadCount: conv.unreadCount || 0, // Retained to support unread badges and ordering logic
updatedAt: conv.updatedAt || conv.lastMessage?.createdAt,
lastMessage: safeLastMessage
};
});

// 4. Hydrate Redis store with the sanitized schema configuration
await cacheService.setConversationList(userId, conversations);
}

return res.status(200).json({
success: true,
data: conversations
});
} catch (error) {
return next(error);
}
});

module.exports = router;
82 changes: 82 additions & 0 deletions apps/web/src/service/cacheService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const redisClient = require('../config/redis');

Check failure on line 1 in apps/web/src/service/cacheService.js

View workflow job for this annotation

GitHub Actions / Lint · Build

A `require()` style import is forbidden

Check failure on line 1 in apps/web/src/service/cacheService.js

View workflow job for this annotation

GitHub Actions / build

A `require()` style import is forbidden

/**
* Retrieves the cached conversation list for a specific user.
* * @param {string} userId - The unique identifier of the user.
* @returns {Promise<Array|null>} Sanitized conversation array or null on cache miss.
*/
async function getConversationList(userId) {
const cacheKey = `user:${userId}:conversations`;
const cachedData = await redisClient.get(cacheKey);
return cachedData ? JSON.parse(cachedData) : null;
}

/**
* Commits a full, pre-sanitized conversation list to the Redis cache.
* * @param {string} userId - The unique identifier of the user.
* @param {Array} conversations - Sanitized conversation objects.
*/
async function setConversationList(userId, conversations) {
const cacheKey = `user:${userId}:conversations`;
// Cache data with a standard 24-hour expiration safety window
await redisClient.set(cacheKey, JSON.stringify(conversations), 'EX', 86400);
}

/**
* Updates a singular conversation entry cache tracking shape securely.
* Called automatically when real-time messages are broadcasted across sockets.
* * Acceptance Criteria Met:
* - Drops structural message body/text/ciphertext fragments.
* - Retains only isolated metadata fields (senderId, senderDeviceId, contentType, sequenceNumber, createdAt).
*/
async function updateConversationCache(userId, conversationId, lastMessagePayload, unreadCount) {
const cacheKey = `user:${userId}:conversations`;

// 1. Fetch the existing cache array list
const cachedData = await redisClient.get(cacheKey);
let list = cachedData ? JSON.parse(cachedData) : [];

// 2. Find if the target conversation context already exists in the array
let convItem = list.find(c => c.id === conversationId);

// 3. SECURE PREVIEW ISOLATION MATRIX
// Manually map out the verified unclassified attributes.
// CRITICAL: Never spread (...lastMessagePayload) as it risks inheriting forbidden message bodies.
const sanitizedMessageMetadata = lastMessagePayload ? {
senderId: lastMessagePayload.senderId,
senderDeviceId: lastMessagePayload.senderDeviceId,
contentType: lastMessagePayload.contentType,
sequenceNumber: lastMessagePayload.sequenceNumber,
createdAt: lastMessagePayload.createdAt
} : null;

const currentTimestamp = lastMessagePayload?.createdAt || new Date().toISOString();

if (convItem) {
// 4a. Update reference nodes on existing entry
convItem.unreadCount = unreadCount;
convItem.lastMessage = sanitizedMessageMetadata;
convItem.updatedAt = currentTimestamp;
} else {
// 4b. Push a brand new sanitized profile block if conversation entry is new
list.push({
id: conversationId,
participants: [], // Will be hydrated on full sync
unreadCount: unreadCount,
lastMessage: sanitizedMessageMetadata,
updatedAt: currentTimestamp
});
}

// 5. Keep the conversation feed list sorted perfectly by latest updates (Ordering Requirement)
list.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));

// 6. Save back to the Redis database instances
await redisClient.set(cacheKey, JSON.stringify(list), 'EX', 86400);
}

module.exports = {
getConversationList,
setConversationList,
updateConversationCache
};
Loading