Files
2026-07-01 11:10:09 -04:00

504 lines
18 KiB
JavaScript

'use strict';
// Tests for endpoints not covered by other test files:
// GET /api/auth/username-suggestions
// POST /api/auth/resetpassword
// POST /api/auth/resetpassword/:token
// POST /api/auth/invite/:token (email-verify step of invite flow)
// POST /api/auth/invite/:token/:mailToken (accept invite, create account)
// POST /api/user/accept-tos
// POST /api/user/key
// GET /api/user/:uid/verification
// GET /api/token/
// GET /api/token/:name
// GET /api/token/:name/:token
const { TEST_CREDS, login, request, app } = require('./setup');
const { PasswordResetToken, InviteToken } = require('../models/token');
// Dedicated test user — created in beforeAll, removed in afterAll.
const TEST_USER = {
givenName: 'Misc',
sn: 'Tester',
mail: 'mtester@test.example.com',
mobile: '5555550099',
userPassword: 'MiscTest!77',
};
const TEST_UID = 'mtester'; // givenName[0] + sn lowercase
// The uid assigned to the invite-accept user (filled in by beforeAll for that describe block).
let createdInviteUid;
let token;
beforeAll(async () => {
token = await login();
// Remove any leftovers from a previous failed run.
await request(app).delete(`/api/user/${TEST_UID}`).set('auth-token', token);
// Clean up potential invite-accept user leftovers (givenName=Invite, sn=Acceptor).
for (const uid of ['iacceptor', 'iacceptor2', 'iacceptor3', 'inviteacceptor', 'invite_acceptor', 'invitea', 'acceptori']) {
await request(app).delete(`/api/user/${uid}`).set('auth-token', token);
}
// Create the test user used by most describes below.
await request(app).post('/api/user/').set('auth-token', token).send(TEST_USER);
});
afterAll(async () => {
await request(app).delete(`/api/user/${TEST_UID}`).set('auth-token', token);
// Clean up any user created by the invite-accept test.
if (createdInviteUid) {
await request(app).delete(`/api/user/${createdInviteUid}`).set('auth-token', token);
}
});
// ---------------------------------------------------------------------------
// GET /api/auth/username-suggestions
// ---------------------------------------------------------------------------
describe('Auth — GET /api/auth/username-suggestions', () => {
test('missing params returns empty suggestions array', async () => {
const res = await request(app).get('/api/auth/username-suggestions');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('suggestions');
expect(res.body.suggestions).toEqual([]);
});
test('only sn (no givenName) returns empty suggestions', async () => {
const res = await request(app)
.get('/api/auth/username-suggestions')
.query({ sn: 'Uniqueish' });
expect(res.status).toBe(200);
expect(res.body.suggestions).toEqual([]);
});
test('givenName + sn returns at least one suggestion', async () => {
const res = await request(app)
.get('/api/auth/username-suggestions')
.query({ givenName: 'Unique', sn: 'Xyzzyabc' });
expect(res.status).toBe(200);
expect(Array.isArray(res.body.suggestions)).toBe(true);
expect(res.body.suggestions.length).toBeGreaterThan(0);
// Primary suggestion should be first-initial + last-name
expect(res.body.suggestions[0]).toMatch(/^uxyzzyabc/);
});
test('dob adds year-suffixed suggestions', async () => {
const res = await request(app)
.get('/api/auth/username-suggestions')
.query({ givenName: 'Dob', sn: 'Testerly', dob: '1990-06-15' });
expect(res.status).toBe(200);
expect(res.body.suggestions.length).toBeGreaterThan(0);
});
});
// ---------------------------------------------------------------------------
// POST /api/auth/resetpassword
// ---------------------------------------------------------------------------
describe('Auth — POST /api/auth/resetpassword', () => {
test('unknown email returns error status', async () => {
const res = await request(app)
.post('/api/auth/resetpassword')
.send({ mail: 'nobody_at_all@noreply.example.com' });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('known email returns 200 with message (SMTP failure is non-fatal)', async () => {
const res = await request(app)
.post('/api/auth/resetpassword')
.send({ mail: TEST_USER.mail });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
});
});
// ---------------------------------------------------------------------------
// POST /api/auth/resetpassword/:token
// ---------------------------------------------------------------------------
describe('Auth — POST /api/auth/resetpassword/:token', () => {
const RESET_PASSWORD = 'ResetPass!44';
test('invalid / unknown token returns error', async () => {
const res = await request(app)
.post('/api/auth/resetpassword/00000000-0000-0000-0000-000000000000')
.send({ userPassword: RESET_PASSWORD });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('valid token resets the password and login succeeds', async () => {
const resetToken = await PasswordResetToken.create({ created_by: TEST_UID });
const res = await request(app)
.post(`/api/auth/resetpassword/${resetToken.token}`)
.send({ userPassword: RESET_PASSWORD });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message');
// Verify the new password works.
const loginRes = await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: RESET_PASSWORD });
expect(loginRes.status).toBe(200);
expect(loginRes.body).toHaveProperty('token');
// Restore the original password so later tests that reuse this user still work.
await request(app)
.put(`/api/user/${TEST_UID}/password`)
.set('auth-token', token)
.send({ userPassword: TEST_USER.userPassword });
});
test('token can only be used once', async () => {
const resetToken = await PasswordResetToken.create({ created_by: TEST_UID });
// First use should succeed.
const first = await request(app)
.post(`/api/auth/resetpassword/${resetToken.token}`)
.send({ userPassword: RESET_PASSWORD });
expect(first.status).toBe(200);
// Restore password before second attempt.
await request(app)
.put(`/api/user/${TEST_UID}/password`)
.set('auth-token', token)
.send({ userPassword: TEST_USER.userPassword });
// Second use of the same token must be rejected.
const second = await request(app)
.post(`/api/auth/resetpassword/${resetToken.token}`)
.send({ userPassword: RESET_PASSWORD });
expect(second.status).toBeGreaterThanOrEqual(400);
});
});
// ---------------------------------------------------------------------------
// POST /api/auth/invite/:token (email-verify step)
// ---------------------------------------------------------------------------
describe('Auth — POST /api/auth/invite/:token (email verification for invite)', () => {
test('invalid token returns error', async () => {
const res = await request(app)
.post('/api/auth/invite/00000000-0000-0000-0000-000000000000')
.send({ mail: 'nobody@test.example.com' });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('valid token updates token mail and responds with sent (SMTP failure is non-fatal)', async () => {
const invToken = await InviteToken.create({ created_by: TEST_CREDS.uid });
const res = await request(app)
.post(`/api/auth/invite/${invToken.token}`)
.send({ mail: 'verifyinvite@test.example.com' });
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('message', 'sent');
// Invalidate the token so it doesn't pollute other tests.
await invToken.update({ is_valid: false });
});
test('email already in use returns error', async () => {
const invToken = await InviteToken.create({ created_by: TEST_CREDS.uid });
// TEST_USER.mail already belongs to mtester — it is "in use".
const res = await request(app)
.post(`/api/auth/invite/${invToken.token}`)
.send({ mail: TEST_USER.mail });
expect(res.status).toBeGreaterThanOrEqual(400);
await invToken.update({ is_valid: false });
});
});
// ---------------------------------------------------------------------------
// POST /api/auth/invite/:token/:mailToken (accept invite, create account)
// ---------------------------------------------------------------------------
describe('Auth — POST /api/auth/invite/:token/:mailToken (accept invite)', () => {
let inviteTokenId;
const INVITE_MAIL_TOKEN = 'misc-suite-mailtoken-12345678901234';
const INVITE_MAIL = 'iacceptor@test.example.com';
const INVITE_GIVENNAME = 'Invite';
const INVITE_SN = 'Acceptor';
const INVITE_PASSWORD = 'InviteAcc!88';
beforeAll(async () => {
// Get a valid username suggestion for this user so we can pass it in the request.
const suggestRes = await request(app)
.get('/api/auth/username-suggestions')
.query({ givenName: INVITE_GIVENNAME, sn: INVITE_SN });
createdInviteUid = suggestRes.body.suggestions[0];
// Build the invite token directly in Redis (avoids needing real SMTP).
const invToken = await InviteToken.create({ created_by: TEST_CREDS.uid });
inviteTokenId = invToken.token;
await invToken.update({ mail: INVITE_MAIL, mail_token: INVITE_MAIL_TOKEN });
});
test('unknown token returns error', async () => {
const res = await request(app)
.post('/api/auth/invite/00000000-0000-0000-0000-000000000000/anytoken')
.send({ givenName: INVITE_GIVENNAME, sn: INVITE_SN, uid: 'nobody', userPassword: INVITE_PASSWORD });
expect(res.status).toBeGreaterThanOrEqual(400);
});
test('invalid uid (not in suggestions) returns 400', async () => {
const res = await request(app)
.post(`/api/auth/invite/${inviteTokenId}/${INVITE_MAIL_TOKEN}`)
.send({ givenName: INVITE_GIVENNAME, sn: INVITE_SN, uid: 'definitely_not_suggested', userPassword: INVITE_PASSWORD });
expect(res.status).toBe(400);
});
test('valid invite creates a new user account and returns an auth token', async () => {
expect(createdInviteUid).toBeDefined();
const res = await request(app)
.post(`/api/auth/invite/${inviteTokenId}/${INVITE_MAIL_TOKEN}`)
.send({
givenName: INVITE_GIVENNAME,
sn: INVITE_SN,
uid: createdInviteUid,
userPassword: INVITE_PASSWORD,
});
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('token');
expect(res.body).toHaveProperty('user', createdInviteUid);
});
test('consumed token cannot be reused', async () => {
const res = await request(app)
.post(`/api/auth/invite/${inviteTokenId}/${INVITE_MAIL_TOKEN}`)
.send({
givenName: INVITE_GIVENNAME,
sn: INVITE_SN,
uid: createdInviteUid,
userPassword: INVITE_PASSWORD,
});
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
// ---------------------------------------------------------------------------
// POST /api/user/accept-tos
// ---------------------------------------------------------------------------
describe('Users — POST /api/user/accept-tos', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).post('/api/user/accept-tos');
expect(res.status).toBe(401);
});
test('marks TOS accepted for the authenticated user', async () => {
const userToken = (await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: TEST_USER.userPassword })).body.token;
const res = await request(app)
.post('/api/user/accept-tos')
.set('auth-token', userToken);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('success', true);
});
test('TOS acceptance is reflected in the verification record', async () => {
const res = await request(app)
.get(`/api/user/${TEST_UID}/verification`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body.tosAccepted).toBe(true);
});
});
// ---------------------------------------------------------------------------
// POST /api/user/key (add SSH public key)
// ---------------------------------------------------------------------------
describe('Users — POST /api/user/key', () => {
// A syntactically valid OpenSSH public key.
const TEST_SSH_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC7Jmtest0123456789abcdefghijklmno test@misc-suite';
test('requires auth — 401 without token', async () => {
const res = await request(app)
.post('/api/user/key')
.send({ key: TEST_SSH_KEY });
expect(res.status).toBe(401);
});
test('authenticated user can add an SSH key', async () => {
const userToken = (await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: TEST_USER.userPassword })).body.token;
const res = await request(app)
.post('/api/user/key')
.set('auth-token', userToken)
.send({ key: TEST_SSH_KEY });
// 200 = added, 400 = already added (both are valid outcomes)
expect([200, 400]).toContain(res.status);
expect(res.body).toHaveProperty('message');
});
});
// ---------------------------------------------------------------------------
// GET /api/user/:uid/verification
// ---------------------------------------------------------------------------
describe('Users — GET /api/user/:uid/verification', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get(`/api/user/${TEST_UID}/verification`);
expect(res.status).toBe(401);
});
test('admin can retrieve verification status for a user', async () => {
const res = await request(app)
.get(`/api/user/${TEST_UID}/verification`)
.set('auth-token', token);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('uid', TEST_UID);
expect(res.body).toHaveProperty('emailVerified');
expect(res.body).toHaveProperty('phoneVerified');
expect(res.body).toHaveProperty('tosAccepted');
expect(res.body).toHaveProperty('tosAcceptedAt');
});
test('non-admin without admin group returns 401', async () => {
const userToken = (await request(app)
.post('/api/auth/login')
.send({ uid: TEST_UID, password: TEST_USER.userPassword })).body.token;
const res = await request(app)
.get(`/api/user/${TEST_UID}/verification`)
.set('auth-token', userToken);
expect(res.status).toBe(401);
});
test('unknown uid returns error', async () => {
const res = await request(app)
.get('/api/user/no_such_user_xyz/verification')
.set('auth-token', token);
// UserVerification.getOrCreate creates a record even for unknowns in some
// implementations; accept 200 or 4xx as long as it responds.
expect(res.status).toBeGreaterThanOrEqual(200);
});
});
// ---------------------------------------------------------------------------
// GET /api/token/
// ---------------------------------------------------------------------------
describe('Tokens — GET /api/token/', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/token/');
expect(res.status).toBe(401);
});
test('returns list of token-type names', async () => {
const res = await request(app)
.get('/api/token/')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
expect(res.body.results.length).toBeGreaterThan(0);
// Base Token class is deleted; known types should include InviteToken.
expect(res.body.results).toContain('InviteToken');
});
});
// ---------------------------------------------------------------------------
// GET /api/token/:name
// ---------------------------------------------------------------------------
describe('Tokens — GET /api/token/:name', () => {
test('requires auth — 401 without token', async () => {
const res = await request(app).get('/api/token/InviteToken');
expect(res.status).toBe(401);
});
test('returns list of token ids for InviteToken', async () => {
const res = await request(app)
.get('/api/token/InviteToken')
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
});
test('detail=true returns full token objects', async () => {
const res = await request(app)
.get('/api/token/InviteToken')
.query({ detail: true })
.set('auth-token', token);
expect(res.status).toBe(200);
expect(Array.isArray(res.body.results)).toBe(true);
// The 'token' key is marked isPrivate in the model, so it is excluded from results.
// Check for other known fields instead.
if (res.body.results.length > 0) {
expect(res.body.results[0]).toHaveProperty('is_valid');
expect(res.body.results[0]).toHaveProperty('created_by');
}
});
test('unknown token type returns error', async () => {
const res = await request(app)
.get('/api/token/NoSuchTokenType')
.set('auth-token', token);
// Token route does tokens[name].listDetail() — undefined.listDetail() throws.
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
// ---------------------------------------------------------------------------
// GET /api/token/:name/:token
// ---------------------------------------------------------------------------
describe('Tokens — GET /api/token/:name/:token', () => {
let knownTokenId;
beforeAll(async () => {
// Create a fresh invite token so we have a known id to fetch.
const res = await request(app)
.post('/api/user/invite')
.set('auth-token', token)
.send({});
knownTokenId = res.body.token;
});
test('requires auth — 401 without token', async () => {
expect(knownTokenId).toBeDefined();
const res = await request(app).get(`/api/token/InviteToken/${knownTokenId}`);
expect(res.status).toBe(401);
});
test('returns the specific token object', async () => {
expect(knownTokenId).toBeDefined();
const res = await request(app)
.get(`/api/token/InviteToken/${knownTokenId}`)
.set('auth-token', token);
expect(res.status).toBe(200);
// The 'token' key is marked isPrivate in the model and is excluded from the serialised object.
// Verify other well-known fields instead.
expect(res.body.results).toHaveProperty('is_valid');
expect(res.body.results).toHaveProperty('created_by', TEST_CREDS.uid);
});
test('unknown token id returns error', async () => {
const res = await request(app)
.get('/api/token/InviteToken/00000000-0000-0000-0000-000000000000')
.set('auth-token', token);
expect(res.status).toBeGreaterThanOrEqual(400);
});
});