mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2026-06-23 04:10:17 +00:00
a880864b2c
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
/*
|
|
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
*/
|
|
import { FieldNameApiToken, TableApiToken } from '@hedgedoc/database';
|
|
import { Provider } from '@nestjs/common';
|
|
import { ConfigModule } from '@nestjs/config';
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import type { Tracker } from 'knex-mock-client';
|
|
|
|
import appConfigMock from '../config/mock/app.config.mock';
|
|
import authConfigMock from '../config/mock/auth.config.mock';
|
|
import { expectBindings } from '../database/mock/expect-bindings';
|
|
import {
|
|
mockDelete,
|
|
mockInsert,
|
|
mockSelect,
|
|
mockUpdate,
|
|
} from '../database/mock/mock-queries';
|
|
import { mockKnexDb } from '../database/mock/provider';
|
|
import { ApiTokenWithSecretDto } from '../dtos/api-token-with-secret.dto';
|
|
import {
|
|
NotInDBError,
|
|
TokenNotValidError,
|
|
TooManyTokensError,
|
|
} from '../errors/errors';
|
|
import { LoggerModule } from '../logger/logger.module';
|
|
import {
|
|
dateTimeToDB,
|
|
getCurrentDateTime,
|
|
isoStringToDateTime,
|
|
} from '../utils/datetime';
|
|
import * as passwordUtils from '../utils/password';
|
|
import { ApiTokenService, AUTH_TOKEN_PREFIX } from './api-token.service';
|
|
|
|
jest.mock('../utils/password');
|
|
|
|
describe('ApiTokenService', () => {
|
|
const validSecret =
|
|
'gNrv_NJ4FHZ0UFZJQu_q_3i3-GP_d6tELVtkYiMFLkLWNl_dxEmPVAsCNKxP3N3DB9aGBVFYE1iptvw7hFMJvA';
|
|
const validKeyId = '12345678901';
|
|
const userId = 1;
|
|
const label = 'test token';
|
|
|
|
const mockCreatedAt = '2025-11-05 20:45:05';
|
|
const mockCreatedAtIso = '2025-11-05T20:45:05.000Z';
|
|
|
|
const mockValidUntil = '2025-12-24 23:59:59';
|
|
const mockValidUntilIso = '2025-12-24T23:59:59.000Z';
|
|
|
|
const mockValidUntilExactly2YearsIso = '2027-11-05T20:45:05.000Z';
|
|
|
|
const mockValidUntilOver2YearsIso = '2027-12-24T23:59:59.000Z';
|
|
|
|
const mockValidUntilOver1YearsIso = '2026-12-24T23:59:59.000Z';
|
|
|
|
const mockLastUsedAt = '2025-12-06 07:14:21';
|
|
const mockLastUsedAtIso = '2025-12-06T07:14:21.000Z';
|
|
|
|
let service: ApiTokenService;
|
|
|
|
let knexProvider: Provider;
|
|
let tracker: Tracker;
|
|
|
|
beforeAll(async () => {
|
|
[tracker, knexProvider] = mockKnexDb();
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [ApiTokenService, knexProvider],
|
|
imports: [
|
|
ConfigModule.forRoot({
|
|
isGlobal: true,
|
|
load: [appConfigMock, authConfigMock],
|
|
}),
|
|
LoggerModule,
|
|
],
|
|
}).compile();
|
|
|
|
service = module.get<ApiTokenService>(ApiTokenService);
|
|
});
|
|
|
|
afterEach(() => {
|
|
tracker.reset();
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
describe('getUserIdForToken', () => {
|
|
describe('fails if', () => {
|
|
it('the keyId has an invalid length', async () => {
|
|
await expect(
|
|
service.getUserIdForToken(
|
|
`${AUTH_TOKEN_PREFIX}.123456789.${validSecret}`,
|
|
),
|
|
).rejects.toThrow(TokenNotValidError);
|
|
});
|
|
it('the secret is missing', async () => {
|
|
await expect(
|
|
service.getUserIdForToken(`${AUTH_TOKEN_PREFIX}.${validKeyId}`),
|
|
).rejects.toThrow(TokenNotValidError);
|
|
});
|
|
it('the secret has an invalid length', async () => {
|
|
await expect(
|
|
service.getUserIdForToken(
|
|
`${AUTH_TOKEN_PREFIX}.${validKeyId}.${'a'.repeat(73)}`,
|
|
),
|
|
).rejects.toThrow(TokenNotValidError);
|
|
});
|
|
it('the prefix is wrong', async () => {
|
|
await expect(
|
|
service.getUserIdForToken(`hd1.${validKeyId}.${validSecret}`),
|
|
).rejects.toThrow(TokenNotValidError);
|
|
});
|
|
it('the token contains sections after the secret', async () => {
|
|
await expect(
|
|
service.getUserIdForToken(
|
|
`${AUTH_TOKEN_PREFIX}.${validKeyId}.${validSecret}.extra`,
|
|
),
|
|
).rejects.toThrow(TokenNotValidError);
|
|
});
|
|
it('the token does not exist in the database', async () => {
|
|
mockSelect(
|
|
tracker,
|
|
[
|
|
FieldNameApiToken.secretHash,
|
|
FieldNameApiToken.userId,
|
|
FieldNameApiToken.validUntil,
|
|
],
|
|
TableApiToken,
|
|
FieldNameApiToken.id,
|
|
[],
|
|
);
|
|
await expect(
|
|
service.getUserIdForToken(
|
|
`${AUTH_TOKEN_PREFIX}.${validKeyId}.${validSecret}`,
|
|
),
|
|
).rejects.toThrow(TokenNotValidError);
|
|
expectBindings(tracker, 'select', [[validKeyId]], true);
|
|
});
|
|
it('ensureTokenIsValid does throw error', async () => {
|
|
mockSelect(
|
|
tracker,
|
|
[
|
|
FieldNameApiToken.secretHash,
|
|
FieldNameApiToken.userId,
|
|
FieldNameApiToken.validUntil,
|
|
],
|
|
TableApiToken,
|
|
FieldNameApiToken.id,
|
|
[
|
|
{
|
|
[FieldNameApiToken.secretHash]: 'foo',
|
|
[FieldNameApiToken.userId]: userId,
|
|
[FieldNameApiToken.validUntil]: '01.01.2020T00:00:00',
|
|
},
|
|
],
|
|
);
|
|
jest.spyOn(service, 'ensureTokenIsValid').mockImplementation(() => {
|
|
throw new TokenNotValidError();
|
|
});
|
|
await expect(
|
|
service.getUserIdForToken(
|
|
`${AUTH_TOKEN_PREFIX}.${validKeyId}.${validSecret}`,
|
|
),
|
|
).rejects.toThrow(TokenNotValidError);
|
|
expectBindings(tracker, 'select', [[validKeyId]], true);
|
|
});
|
|
});
|
|
it('works', async () => {
|
|
mockSelect(
|
|
tracker,
|
|
[
|
|
FieldNameApiToken.secretHash,
|
|
FieldNameApiToken.userId,
|
|
FieldNameApiToken.validUntil,
|
|
],
|
|
TableApiToken,
|
|
FieldNameApiToken.id,
|
|
[
|
|
{
|
|
[FieldNameApiToken.secretHash]: 'foo',
|
|
[FieldNameApiToken.userId]: userId,
|
|
[FieldNameApiToken.validUntil]: '01.01.2020T00:00:00',
|
|
},
|
|
],
|
|
);
|
|
mockUpdate(
|
|
tracker,
|
|
TableApiToken,
|
|
[FieldNameApiToken.lastUsedAt],
|
|
FieldNameApiToken.id,
|
|
1,
|
|
);
|
|
jest.spyOn(service, 'ensureTokenIsValid').mockImplementation(() => {});
|
|
const userByToken = await service.getUserIdForToken(
|
|
`${AUTH_TOKEN_PREFIX}.${validKeyId}.${validSecret}`,
|
|
);
|
|
expect(userByToken).toEqual(userId);
|
|
expectBindings(tracker, 'select', [[validKeyId]], true);
|
|
});
|
|
});
|
|
|
|
describe('createToken', () => {
|
|
describe('fails if', () => {
|
|
it('user has more than 200 tokens', async () => {
|
|
mockSelect(
|
|
tracker,
|
|
[FieldNameApiToken.id],
|
|
TableApiToken,
|
|
FieldNameApiToken.userId,
|
|
Array(201).fill({
|
|
[FieldNameApiToken.id]: '1',
|
|
}),
|
|
);
|
|
await expect(
|
|
service.createToken(
|
|
userId,
|
|
label,
|
|
isoStringToDateTime(mockValidUntilIso),
|
|
),
|
|
).rejects.toThrow(TooManyTokensError);
|
|
});
|
|
});
|
|
|
|
describe('works', () => {
|
|
let token: ApiTokenWithSecretDto;
|
|
const mockSecretHash = 'a'.repeat(20);
|
|
let expectedValidUntil: string;
|
|
|
|
beforeEach(() => {
|
|
jest.useFakeTimers().setSystemTime(new Date(mockCreatedAtIso));
|
|
jest
|
|
.spyOn(passwordUtils, 'bufferToBase64Url')
|
|
.mockReturnValue(validSecret)
|
|
.mockReturnValue(validKeyId);
|
|
jest
|
|
.spyOn(passwordUtils, 'hashApiToken')
|
|
.mockReturnValue(mockSecretHash);
|
|
token = {} as ApiTokenWithSecretDto;
|
|
mockSelect(
|
|
tracker,
|
|
[FieldNameApiToken.id],
|
|
TableApiToken,
|
|
FieldNameApiToken.userId,
|
|
[],
|
|
);
|
|
mockInsert(tracker, TableApiToken, [
|
|
FieldNameApiToken.createdAt,
|
|
FieldNameApiToken.id,
|
|
FieldNameApiToken.label,
|
|
FieldNameApiToken.lastUsedAt,
|
|
FieldNameApiToken.secretHash,
|
|
FieldNameApiToken.userId,
|
|
FieldNameApiToken.validUntil,
|
|
]);
|
|
expectedValidUntil = mockValidUntilExactly2YearsIso;
|
|
});
|
|
afterEach(() => {
|
|
expect(token.label).toEqual(label);
|
|
expect(token.validUntil).toEqual(expectedValidUntil);
|
|
expect(token.lastUsedAt).toBeNull();
|
|
expect(
|
|
token.secret.startsWith(AUTH_TOKEN_PREFIX + '.' + token.keyId),
|
|
).toBe(true);
|
|
expectBindings(tracker, 'select', [[userId]]);
|
|
expectBindings(tracker, 'insert', [
|
|
[
|
|
dateTimeToDB(isoStringToDateTime(mockCreatedAtIso)),
|
|
validKeyId,
|
|
label,
|
|
null,
|
|
mockSecretHash,
|
|
userId,
|
|
dateTimeToDB(isoStringToDateTime(expectedValidUntil)),
|
|
],
|
|
]);
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
// expect is common in this test group, and therefore called in afterEach instead of each test
|
|
// oxlint-disable-next-line jest/expect-expect
|
|
it('without validUntil', async () => {
|
|
token = await service.createToken(userId, label);
|
|
});
|
|
|
|
// oxlint-disable-next-line jest/expect-expect
|
|
it('with validUntil more than two years in the future', async () => {
|
|
token = await service.createToken(
|
|
userId,
|
|
label,
|
|
isoStringToDateTime(mockValidUntilOver2YearsIso),
|
|
);
|
|
});
|
|
|
|
// oxlint-disable-next-line jest/expect-expect
|
|
it('with validUntil less than two years in the future', async () => {
|
|
token = await service.createToken(
|
|
userId,
|
|
label,
|
|
isoStringToDateTime(mockValidUntilOver1YearsIso),
|
|
);
|
|
expectedValidUntil = mockValidUntilOver1YearsIso;
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ensureTokenIsValid', () => {
|
|
describe('fails if', () => {
|
|
it('validUntil is in the past', () => {
|
|
expect(() =>
|
|
service.ensureTokenIsValid(
|
|
validSecret,
|
|
'',
|
|
getCurrentDateTime().minus({
|
|
day: 1,
|
|
}),
|
|
),
|
|
).toThrow(TokenNotValidError);
|
|
});
|
|
it('if checkTokenEquality returns false', () => {
|
|
jest.spyOn(passwordUtils, 'checkTokenEquality').mockReturnValue(false);
|
|
expect(() =>
|
|
service.ensureTokenIsValid(
|
|
validSecret,
|
|
'',
|
|
getCurrentDateTime().minus({
|
|
day: 1,
|
|
}),
|
|
),
|
|
).toThrow(TokenNotValidError);
|
|
});
|
|
});
|
|
|
|
it('works', () => {
|
|
jest.spyOn(passwordUtils, 'checkTokenEquality').mockReturnValue(true);
|
|
expect(() =>
|
|
service.ensureTokenIsValid(
|
|
validSecret,
|
|
'',
|
|
getCurrentDateTime().plus({
|
|
day: 1,
|
|
}),
|
|
),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
describe('getTokensOfUserById', () => {
|
|
it('works', async () => {
|
|
mockSelect(
|
|
tracker,
|
|
[
|
|
FieldNameApiToken.createdAt,
|
|
FieldNameApiToken.id,
|
|
FieldNameApiToken.label,
|
|
FieldNameApiToken.lastUsedAt,
|
|
FieldNameApiToken.validUntil,
|
|
],
|
|
TableApiToken,
|
|
FieldNameApiToken.userId,
|
|
[
|
|
{
|
|
[FieldNameApiToken.id]: validKeyId,
|
|
[FieldNameApiToken.label]: label,
|
|
[FieldNameApiToken.validUntil]: mockValidUntil,
|
|
[FieldNameApiToken.createdAt]: mockCreatedAt,
|
|
[FieldNameApiToken.lastUsedAt]: mockLastUsedAt,
|
|
},
|
|
],
|
|
);
|
|
const tokens = await service.getTokensOfUserById(userId);
|
|
expect(tokens).toHaveLength(1);
|
|
expect(tokens).toEqual([
|
|
{
|
|
label: label,
|
|
validUntil: mockValidUntilIso,
|
|
keyId: validKeyId,
|
|
createdAt: mockCreatedAtIso,
|
|
lastUsedAt: mockLastUsedAtIso,
|
|
},
|
|
]);
|
|
expectBindings(tracker, 'select', [[userId]]);
|
|
});
|
|
it('should return empty array if token for user does not exist', async () => {
|
|
mockSelect(
|
|
tracker,
|
|
[
|
|
FieldNameApiToken.createdAt,
|
|
FieldNameApiToken.id,
|
|
FieldNameApiToken.label,
|
|
FieldNameApiToken.lastUsedAt,
|
|
FieldNameApiToken.validUntil,
|
|
],
|
|
TableApiToken,
|
|
FieldNameApiToken.userId,
|
|
[],
|
|
);
|
|
const tokens = await service.getTokensOfUserById(userId);
|
|
expect(tokens).toHaveLength(0);
|
|
expect(tokens).toEqual([]);
|
|
expectBindings(tracker, 'select', [[userId]]);
|
|
});
|
|
});
|
|
|
|
describe('removeToken', () => {
|
|
it('throws if the token is not in the database', async () => {
|
|
mockDelete(
|
|
tracker,
|
|
TableApiToken,
|
|
[FieldNameApiToken.id, FieldNameApiToken.userId],
|
|
0,
|
|
);
|
|
await expect(service.removeToken(validKeyId, userId)).rejects.toThrow(
|
|
NotInDBError,
|
|
);
|
|
});
|
|
it('works', async () => {
|
|
mockDelete(
|
|
tracker,
|
|
TableApiToken,
|
|
[FieldNameApiToken.id, FieldNameApiToken.userId],
|
|
1,
|
|
);
|
|
await service.removeToken(validKeyId, userId);
|
|
expectBindings(tracker, 'delete', [[validKeyId, userId]]);
|
|
});
|
|
});
|
|
|
|
describe('removeInvalidTokens', () => {
|
|
it('works', async () => {
|
|
const mockTime = isoStringToDateTime(mockCreatedAtIso);
|
|
jest.useFakeTimers().setSystemTime(new Date(mockCreatedAtIso));
|
|
mockDelete(tracker, TableApiToken, [FieldNameApiToken.validUntil], 1);
|
|
await service.removeInvalidTokens();
|
|
expectBindings(tracker, 'delete', [[dateTimeToDB(mockTime)]]);
|
|
jest.useRealTimers();
|
|
});
|
|
});
|
|
|
|
describe('auto remove invalid tokens', () => {
|
|
beforeEach(() => {
|
|
jest
|
|
.spyOn(service, 'removeInvalidTokens')
|
|
.mockImplementation(async () => {});
|
|
});
|
|
|
|
it('handleCron should call removeInvalidTokens', async () => {
|
|
await service.handleCron();
|
|
expect(service.removeInvalidTokens).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('handleTimeout should call removeInvalidTokens', async () => {
|
|
await service.handleTimeout();
|
|
expect(service.removeInvalidTokens).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|