Skip to content
Merged
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
24 changes: 24 additions & 0 deletions backend/__tests__/service/users.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,28 @@ describe('User Routes Integration Tests', () => {
const updated = await User.findById(user._id);
expect(updated.profilePicture).toBe('/api/uploads/newpic.jpg');
});

it('should save the account display name without changing its username or email', async () => {
const user = new User({
username: 'testuser',
email: 'test@example.com',
password: 'Password123!',
verified: true,
});
await user.save();
const token = generateTestToken(user._id);

const res = await request(app)
.put('/api/users/profile')
.set('Authorization', `Bearer ${token}`)
.send({ displayName: 'Test User' })
.expect(200);

expect(res.body.displayName).toBe('Test User');
expect(res.body.email).toBe('test@example.com');

const updated = await User.findById(user._id);
expect(updated.displayName).toBe('Test User');
expect(updated.verified).toBe(true);
});
});
94 changes: 93 additions & 1 deletion backend/__tests__/unit/controllers/userController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ jest.mock('../../../services/agentIdentityService', () => ({

const User = require('../../../models/User');
const userController = require('../../../controllers/userController');
const { resolveAgentDisplayLabel } = require('../../../services/agentIdentityService');
const { resolveAgentDisplayLabel, syncUserToPostgreSQL } = require('../../../services/agentIdentityService');

const mockUserDoc = (fields) => ({
...fields,
Expand All @@ -15,6 +15,13 @@ const mockUserDoc = (fields) => ({
toObject: () => fields,
});

const mockDisplayNameLookup = (result) => {
const select = jest.fn().mockResolvedValue(result);
const collation = jest.fn().mockReturnValue({ select });
User.findOne = jest.fn().mockReturnValue({ collation });
return { select, collation };
};

describe('User Controller', () => {
afterEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -76,6 +83,91 @@ describe('User Controller', () => {
profilePicture: '/api/uploads/newpic.png',
}));
});

it('updates a human display name without changing the routable username', async () => {
const updatedUser = mockUserDoc({
_id: 'u1', username: 'lily', displayName: 'Lily Shen', email: 'lily@example.com', verified: true,
});
User.findByIdAndUpdate = jest
.fn()
.mockReturnValue({ select: jest.fn().mockResolvedValueOnce(updatedUser) });
mockDisplayNameLookup(null);
const req = {
user: { id: 'u1' },
body: { displayName: 'Lily Shen' },
};
const res = { json: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() };

await userController.updateProfile(req, res);

expect(User.findByIdAndUpdate).toHaveBeenCalledWith(
'u1',
{ $set: { displayName: 'Lily Shen' } },
{ new: true },
);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
displayName: 'Lily Shen', email: 'lily@example.com',
}));
});

it('rejects a case-insensitive display name collision with another account', async () => {
const collision = mockDisplayNameLookup({ _id: 'u2' });
const req = { user: { id: 'u1' }, body: { displayName: 'LiLy' } };
const res = { json: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() };

await userController.updateProfile(req, res);

expect(User.findOne).toHaveBeenCalledWith({
_id: { $ne: 'u1' },
$or: [
{ username: 'LiLy' },
{ displayName: 'LiLy' },
{ 'botMetadata.displayName': 'LiLy' },
],
});
expect(collision.collation).toHaveBeenCalledWith({ locale: 'en', strength: 2 });
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'That name belongs to someone else here' });
expect(User.findByIdAndUpdate).not.toHaveBeenCalled();
});

it('excludes the current account when checking a display name collision', async () => {
const lookup = mockDisplayNameLookup(null);
const updatedUser = mockUserDoc({ _id: 'u1', username: 'lily', displayName: 'Lily' });
User.findByIdAndUpdate = jest
.fn()
.mockReturnValue({ select: jest.fn().mockResolvedValueOnce(updatedUser) });
const req = { user: { id: 'u1' }, body: { displayName: 'Lily' } };
const res = { json: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() };

await userController.updateProfile(req, res);

expect(User.findOne).toHaveBeenCalledWith(expect.objectContaining({ _id: { $ne: 'u1' } }));
expect(lookup.select).toHaveBeenCalledWith('_id');
expect(User.findByIdAndUpdate).toHaveBeenCalledWith(
'u1',
{ $set: { displayName: 'Lily' } },
{ new: true },
);
});

it('does not report a display-name save as complete when the chat mirror is unavailable', async () => {
const updatedUser = mockUserDoc({ _id: 'u1', username: 'lily', displayName: 'Lily Shen' });
User.findByIdAndUpdate = jest
.fn()
.mockReturnValue({ select: jest.fn().mockResolvedValueOnce(updatedUser) });
mockDisplayNameLookup(null);
syncUserToPostgreSQL.mockResolvedValueOnce(false);
const req = { user: { id: 'u1' }, body: { displayName: 'Lily Shen' } };
const res = { json: jest.fn(), status: jest.fn().mockReturnThis(), send: jest.fn() };

await userController.updateProfile(req, res);

expect(res.status).toHaveBeenCalledWith(503);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
error: expect.stringContaining('chat is catching up'),
}));
});
});

describe('getUserById', () => {
Expand Down
46 changes: 46 additions & 0 deletions backend/__tests__/unit/models/User.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,52 @@ describe('User Model Tests', () => {
await expect(secondUser.save()).rejects.toThrow();
});

it('indexes account labels for case-insensitive display-name collision checks', () => {
const matchingIndexes = User.schema.indexes().filter(([, options]) => (
options.collation?.locale === 'en' && options.collation?.strength === 2
));

const indexedFields = matchingIndexes.map(([keys]) => Object.keys(keys)[0]);
expect(indexedFields).toEqual(expect.arrayContaining([
'username',
'displayName',
'botMetadata.displayName',
]));
});

it('matches account labels case-insensitively while excluding the current user', async () => {
const owner = await User.create({
username: 'LilyHandle',
displayName: 'Lily',
email: 'lily@example.com',
password: 'Password123!',
});
const other = await User.create({
username: 'other',
email: 'other@example.com',
password: 'Password123!',
});
const collisionQuery = {
$or: [
{ username: 'lilyhandle' },
{ displayName: 'lilyhandle' },
{ 'botMetadata.displayName': 'lilyhandle' },
],
};

const selfExcluded = await User.findOne({
_id: { $ne: owner._id },
...collisionQuery,
}).collation({ locale: 'en', strength: 2 });
const otherExcluded = await User.findOne({
_id: { $ne: other._id },
...collisionQuery,
}).collation({ locale: 'en', strength: 2 });

expect(selfExcluded).toBeNull();
expect(otherExcluded?._id.toString()).toBe(owner._id.toString());
});

it('should hash the password before saving', async () => {
const password = 'Password123!';
const user = new User({
Expand Down
84 changes: 79 additions & 5 deletions backend/__tests__/unit/routes/users.social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,102 @@ jest.mock('../../../controllers/userController', () => ({
unfollowUser: jest.fn((req, res) => res.status(200).end()),
}));

jest.mock('../../../middleware/auth', () => (req, res, next) => next());

const routes = require('../../../routes/users');
const controllers = require('../../../controllers/userController');
jest.mock('../../../middleware/auth', () => (req, res, next) => {
const userId = req.get('authorization') || 'u1';
req.user = { id: userId };
req.userId = userId;
next();
});

describe('users social routes', () => {
const buildApp = () => {
// Re-import the router for every test so each rate limiter gets an isolated
// memory store. Otherwise a quota test can make a later test pass or fail
// because of requests it did not send.
jest.resetModules();
// eslint-disable-next-line global-require, import/no-unresolved, import/extensions
const routes = require('../../../routes/users');
// eslint-disable-next-line global-require, import/no-unresolved, import/extensions
const controllers = require('../../../controllers/userController');
const app = express();
app.use(express.json());
app.use('/api/users', routes);

return { app, controllers };
};

describe('users social routes', () => {
it('POST /api/users/:id/follow calls followUser', async () => {
const { app, controllers } = buildApp();
await request(app).post('/api/users/user123/follow').send({}).expect(200);
expect(controllers.followUser).toHaveBeenCalled();
});

it('DELETE /api/users/:id/follow calls unfollowUser', async () => {
const { app, controllers } = buildApp();
await request(app).delete('/api/users/user123/follow').expect(200);
expect(controllers.unfollowUser).toHaveBeenCalled();
});

it('GET /api/users/:id/public-activity calls getUserPublicActivity', async () => {
const { app, controllers } = buildApp();
await request(app).get('/api/users/user123/public-activity').expect(200);
expect(controllers.getUserPublicActivity).toHaveBeenCalled();
});

it('PUT /api/users/profile mounts the profile write limiter', async () => {
const { app, controllers } = buildApp();
const response = await request(app).put('/api/users/profile').send({ displayName: 'Lily' });

expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-policy');
expect(response.headers['ratelimit-policy']).toContain('30;w=900');
expect(controllers.updateProfile).toHaveBeenCalled();
});

it('lets each authenticated user use 30 profile writes and rejects the 31st', async () => {
const { app } = buildApp();
const accountA = 'Bearer account-a';
const accountB = 'Bearer account-b';

for (let attempt = 0; attempt < 30; attempt += 1) {
// eslint-disable-next-line no-await-in-loop
await request(app).put('/api/users/profile').set('Authorization', accountA).send({})
.expect(200);
}

for (let attempt = 0; attempt < 30; attempt += 1) {
// eslint-disable-next-line no-await-in-loop
await request(app).put('/api/users/profile').set('Authorization', accountB).send({})
.expect(200);
}

const capped = await request(app).put('/api/users/profile').set('Authorization', accountA).send({});
expect(capped.status).toBe(429);
expect(capped.headers['ratelimit-policy']).toContain('30;w=900');
expect(capped.body).toEqual({ msg: 'rate limit exceeded: 30 profile writes per 15 minutes' });
});

it('rejects the 121st profile write from one address before auth', async () => {
const { app } = buildApp();

for (let attempt = 0; attempt < 120; attempt += 1) {
// Unique credentials prove the ingress cap is independent of the
// per-user limit mounted after auth.
// eslint-disable-next-line no-await-in-loop
await request(app).put('/api/users/profile')
.set('Authorization', `Bearer ingress-account-${attempt}`)
.send({})
.expect(200);
}

const capped = await request(app).put('/api/users/profile')
.set('Authorization', 'Bearer ingress-account-121')
.send({});

expect(capped.status).toBe(429);
expect(capped.headers['ratelimit-policy']).toContain('120;w=900');
expect(capped.body).toEqual({
msg: 'rate limit exceeded: too many profile writes from this address',
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const mockQuery = jest.fn();

jest.mock('../../../models/User', () => ({}));
jest.mock('../../../models/Pod', () => ({}));
jest.mock('../../../models/pg/Message', () => ({}));
jest.mock('../../../models/pg/Pod', () => ({}));
jest.mock('../../../config/db-pg', () => ({ pool: { query: mockQuery } }));
jest.mock('../../../services/avatarService', () => ({ normalizeAvatarUrl: jest.fn((value) => value) }));

const AgentIdentityService = require('../../../services/agentIdentityService');

describe('syncUserToPostgreSQL human display names', () => {
const originalPgHost = process.env.PG_HOST;

beforeEach(() => {
process.env.PG_HOST = 'postgres.test';
mockQuery.mockReset();
});

afterAll(() => {
if (originalPgHost === undefined) delete process.env.PG_HOST;
else process.env.PG_HOST = originalPgHost;
});

test('writes a human displayName into the PostgreSQL render column used by the next chat message', async () => {
mockQuery
.mockResolvedValueOnce({ rows: [{ _id: 'human-1' }] })
.mockResolvedValueOnce({ rows: [] });
const user = {
_id: { toString: () => 'human-1' },
username: 'lily',
displayName: 'Lily Shen',
profilePicture: '/uploads/lily.png',
isBot: false,
createdAt: new Date('2026-09-04T20:00:00.000Z'),
};

await expect(AgentIdentityService.syncUserToPostgreSQL(user)).resolves.toBe(true);

expect(mockQuery).toHaveBeenNthCalledWith(1, 'SELECT _id FROM users WHERE _id = $1', ['human-1']);
expect(mockQuery).toHaveBeenNthCalledWith(
2,
expect.stringContaining('SET username = $2'),
['human-1', 'Lily Shen', '/uploads/lily.png', false, expect.any(Date)],
);
});

test('reports a failed mirror so the account save can ask the user to retry', async () => {
const error = jest.spyOn(console, 'error').mockImplementation(() => {});
mockQuery.mockRejectedValueOnce(new Error('postgres down'));
const user = {
_id: { toString: () => 'human-1' },
username: 'lily',
displayName: 'Lily Shen',
profilePicture: '/uploads/lily.png',
isBot: false,
createdAt: new Date(),
};

await expect(AgentIdentityService.syncUserToPostgreSQL(user)).resolves.toBe(false);
error.mockRestore();
});
});
Loading
Loading