mirror of
https://github.com/seerr-team/seerr.git
synced 2026-10-11 22:49:57 +00:00
test: support server-side unit testing (#2485)
This commit is contained in:
@@ -127,6 +127,51 @@ jobs:
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
unit-test:
|
||||
name: Unit Tests
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-24.04
|
||||
container: node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
permissions:
|
||||
checks: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Pnpm Setup
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: sh
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CI: true
|
||||
run: pnpm install
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
CI: true
|
||||
run: pnpm test
|
||||
|
||||
- name: Publish test report
|
||||
uses: mikepenz/action-junit-report@74626db7353a25a20a72816467ebf035f674c5f8 # v6.2.0
|
||||
if: success() || failure() # always run even if the previous step fails
|
||||
with:
|
||||
report_paths: 'report.xml'
|
||||
|
||||
build:
|
||||
name: Build (per-arch, native runners)
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
lcov.info
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
Vendored
+2
-1
@@ -16,6 +16,7 @@
|
||||
|
||||
"stylelint.vscode-stylelint",
|
||||
|
||||
"bradlc.vscode-tailwindcss"
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"firsttris.vscode-jest-runner"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+8
-1
@@ -23,5 +23,12 @@
|
||||
"i18n-ally.localesPaths": [
|
||||
"src/i18n/locale"
|
||||
],
|
||||
"yaml.format.singleQuote": true
|
||||
"yaml.format.singleQuote": true,
|
||||
"jestrunner.enableTestExplorer": true,
|
||||
"jestrunner.defaultTestPatterns": [
|
||||
"server/**/*.{test,spec}.?(c|m)[jt]s?(x)",
|
||||
],
|
||||
"jestrunner.nodeTestCommand": "pnpm test",
|
||||
"jestrunner.changeDirectoryToWorkspaceRoot": true,
|
||||
"jestrunner.projectPath": "."
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"build": "pnpm build:next && pnpm build:server",
|
||||
"lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache",
|
||||
"lintfix": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --fix",
|
||||
"test": "node server/test/index.mts",
|
||||
"start": "NODE_ENV=production node dist/index.js",
|
||||
"i18n:extract": "ts-node --project server/tsconfig.json src/i18n/extractMessages.ts",
|
||||
"migration:generate": "ts-node -r tsconfig-paths/register --project server/tsconfig.json ./node_modules/typeorm/cli.js migration:generate -d server/datasource.ts",
|
||||
@@ -137,6 +138,7 @@
|
||||
"@types/react-transition-group": "4.4.12",
|
||||
"@types/secure-random-password": "0.2.1",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/validator": "^13.15.10",
|
||||
"@types/web-push": "3.6.4",
|
||||
@@ -147,6 +149,7 @@
|
||||
"@typescript-eslint/parser": "7.18.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"baseline-browser-mapping": "^2.8.32",
|
||||
"commander": "^14.0.3",
|
||||
"commitizen": "4.3.1",
|
||||
"copyfiles": "2.4.1",
|
||||
"cy-mobile-commands": "0.3.0",
|
||||
@@ -168,6 +171,7 @@
|
||||
"prettier": "3.8.1",
|
||||
"prettier-plugin-organize-imports": "4.3.0",
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"supertest": "^7.2.2",
|
||||
"tailwindcss": "3.4.19",
|
||||
"ts-node": "10.9.2",
|
||||
"tsc-alias": "1.8.16",
|
||||
|
||||
Generated
+1594
-4
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -38,6 +38,17 @@ function buildSslConfig(): TlsOptions | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
const testConfig: DataSourceOptions = {
|
||||
type: 'sqlite',
|
||||
database: ':memory:',
|
||||
synchronize: true,
|
||||
dropSchema: true,
|
||||
logging: boolFromEnv('DB_LOG_QUERIES'),
|
||||
entities: ['server/entity/**/*.ts'],
|
||||
migrations: ['server/migration/sqlite/**/*.ts'],
|
||||
subscribers: ['server/subscriber/**/*.ts'],
|
||||
};
|
||||
|
||||
const devConfig: DataSourceOptions = {
|
||||
type: 'sqlite',
|
||||
database: process.env.CONFIG_DIRECTORY
|
||||
@@ -105,7 +116,9 @@ const postgresProdConfig: DataSourceOptions = {
|
||||
export const isPgsql = process.env.DB_TYPE === 'postgres';
|
||||
|
||||
function getDataSource(): DataSourceOptions {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return testConfig;
|
||||
} else if (process.env.NODE_ENV === 'production') {
|
||||
return isPgsql ? postgresProdConfig : prodConfig;
|
||||
} else {
|
||||
return isPgsql ? postgresDevConfig : devConfig;
|
||||
|
||||
@@ -79,7 +79,7 @@ export class User {
|
||||
@Column({ nullable: true, select: false })
|
||||
public resetPasswordGuid?: string;
|
||||
|
||||
@Column({ type: 'date', nullable: true })
|
||||
@DbAwareColumn({ type: 'datetime', nullable: true })
|
||||
public recoveryLinkExpirationDate?: Date | null;
|
||||
|
||||
@Column({ type: 'integer', default: UserType.PLEX })
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RecoveryLinkExpirationDateTime1771337333450 implements MigrationInterface {
|
||||
name = 'RecoveryLinkExpirationDateTime1771337333450';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user" ALTER COLUMN "recoveryLinkExpirationDate" TYPE TIMESTAMP WITH TIME ZONE`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user" ALTER COLUMN "recoveryLinkExpirationDate" TYPE date USING ("recoveryLinkExpirationDate"::date)`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class RecoveryLinkExpirationDateTime1771337037917 implements MigrationInterface {
|
||||
name = 'RecoveryLinkExpirationDateTime1771337037917';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" datetime, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, "jellyfinUsername" varchar, "jellyfinAuthToken" varchar, "jellyfinUserId" varchar, "jellyfinDeviceId" varchar, "avatarETag" varchar, "avatarVersion" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate", "movieQuotaLimit", "movieQuotaDays", "tvQuotaLimit", "tvQuotaDays", "jellyfinUsername", "jellyfinAuthToken", "jellyfinUserId", "jellyfinDeviceId", "avatarETag", "avatarVersion" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, beforeEach, describe, it, mock } from 'node:test';
|
||||
|
||||
import { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import PreparedEmail from '@server/lib/email';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { checkUser } from '@server/middleware/auth';
|
||||
import { setupTestDb } from '@server/test/db';
|
||||
import type { Express } from 'express';
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import request from 'supertest';
|
||||
import authRoutes from './auth';
|
||||
|
||||
const emailMock = mock.method(PreparedEmail.prototype, 'send', async () => {
|
||||
return undefined;
|
||||
}).mock;
|
||||
|
||||
let app: Express;
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
session({
|
||||
secret: 'test-secret',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
})
|
||||
);
|
||||
app.use(checkUser);
|
||||
app.use('/auth', authRoutes);
|
||||
// Error handler matching how next({ status, message }) calls are handled
|
||||
app.use(
|
||||
(
|
||||
err: { status?: number; message?: string },
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
// We must provide a next function for the function signature here even though its not used
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_next: express.NextFunction
|
||||
) => {
|
||||
res
|
||||
.status(err.status ?? 500)
|
||||
.json({ status: err.status ?? 500, message: err.message });
|
||||
}
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
app = createApp();
|
||||
});
|
||||
|
||||
setupTestDb();
|
||||
|
||||
/** Create a supertest agent that is logged in as the given user. */
|
||||
async function authenticatedAgent(email: string, password: string) {
|
||||
const agent = request.agent(app);
|
||||
const settings = getSettings();
|
||||
settings.main.localLogin = true;
|
||||
|
||||
const res = await agent.post('/auth/local').send({ email, password });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
return agent;
|
||||
}
|
||||
|
||||
describe('GET /auth/me', () => {
|
||||
it('returns 403 when not authenticated', async () => {
|
||||
const res = await request(app).get('/auth/me');
|
||||
assert.strictEqual(res.status, 403);
|
||||
});
|
||||
|
||||
it('returns the authenticated user', async () => {
|
||||
const agent = await authenticatedAgent('[email protected]', 'test1234');
|
||||
|
||||
const res = await agent.get('/auth/me');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok('id' in res.body);
|
||||
assert.strictEqual(res.body.displayName, 'admin');
|
||||
});
|
||||
|
||||
it('includes userEmailRequired warning when email is required but invalid', async () => {
|
||||
const settings = getSettings();
|
||||
settings.notifications.agents.email.options.userEmailRequired = true;
|
||||
|
||||
// Change the user's email to something invalid
|
||||
const userRepo = getRepository(User);
|
||||
const user = await userRepo.findOneOrFail({
|
||||
where: { email: '[email protected]' },
|
||||
});
|
||||
user.email = 'not-an-email';
|
||||
await userRepo.save(user);
|
||||
|
||||
// Log in with the changed email
|
||||
const agent = request.agent(app);
|
||||
settings.main.localLogin = true;
|
||||
const loginRes = await agent
|
||||
.post('/auth/local')
|
||||
.send({ email: 'not-an-email', password: 'test1234' });
|
||||
assert.strictEqual(loginRes.status, 200);
|
||||
|
||||
const res = await agent.get('/auth/me');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok(res.body.warnings.includes('userEmailRequired'));
|
||||
|
||||
settings.notifications.agents.email.options.userEmailRequired = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/local', () => {
|
||||
beforeEach(() => {
|
||||
const settings = getSettings();
|
||||
settings.main.localLogin = true;
|
||||
});
|
||||
|
||||
it('returns 200 and user data on valid credentials', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ email: '[email protected]', password: 'test1234' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok('id' in res.body);
|
||||
// filter() strips sensitive fields like password
|
||||
assert.ok(!('password' in res.body));
|
||||
});
|
||||
|
||||
it('returns 403 on wrong password', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ email: '[email protected]', password: 'wrongpassword' });
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
assert.strictEqual(res.body.message, 'Access denied.');
|
||||
});
|
||||
|
||||
it('returns 403 for nonexistent user', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ email: '[email protected]', password: 'test1234' });
|
||||
|
||||
assert.strictEqual(res.status, 403);
|
||||
assert.strictEqual(res.body.message, 'Access denied.');
|
||||
});
|
||||
|
||||
it('returns 500 when local login is disabled', async () => {
|
||||
const settings = getSettings();
|
||||
settings.main.localLogin = false;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ email: '[email protected]', password: 'test1234' });
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
assert.strictEqual(res.body.error, 'Password sign-in is disabled.');
|
||||
});
|
||||
|
||||
it('returns 500 when email is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ password: 'test1234' });
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
assert.match(res.body.error, /email address and a password/);
|
||||
});
|
||||
|
||||
it('returns 500 when password is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ email: '[email protected]' });
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
assert.match(res.body.error, /email address and a password/);
|
||||
});
|
||||
|
||||
it('is case-insensitive for email', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ email: '[email protected]', password: 'test1234' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok('id' in res.body);
|
||||
});
|
||||
|
||||
it('allows the non-admin user to log in', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ email: '[email protected]', password: 'test1234' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.ok('id' in res.body);
|
||||
});
|
||||
|
||||
it('sets a session on successful login', async () => {
|
||||
const agent = request.agent(app);
|
||||
|
||||
await agent
|
||||
.post('/auth/local')
|
||||
.send({ email: '[email protected]', password: 'test1234' });
|
||||
|
||||
// Session should persist — /me should succeed
|
||||
const meRes = await agent.get('/auth/me');
|
||||
assert.strictEqual(meRes.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/logout', () => {
|
||||
it('returns 200 when not logged in', async () => {
|
||||
const res = await request(app).post('/auth/logout');
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.status, 'ok');
|
||||
});
|
||||
|
||||
it('destroys session and returns 200 when logged in', async () => {
|
||||
const agent = await authenticatedAgent('[email protected]', 'test1234');
|
||||
|
||||
// Verify session is active
|
||||
const meBeforeRes = await agent.get('/auth/me');
|
||||
assert.strictEqual(meBeforeRes.status, 200);
|
||||
|
||||
const logoutRes = await agent.post('/auth/logout');
|
||||
assert.strictEqual(logoutRes.status, 200);
|
||||
assert.strictEqual(logoutRes.body.status, 'ok');
|
||||
|
||||
// Session should be invalidated — /me should fail
|
||||
const meAfterRes = await agent.get('/auth/me');
|
||||
assert.strictEqual(meAfterRes.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/reset-password', () => {
|
||||
beforeEach(() => {
|
||||
emailMock.resetCalls();
|
||||
});
|
||||
|
||||
it('returns 200 for a valid email', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/reset-password')
|
||||
.send({ email: '[email protected]' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.status, 'ok');
|
||||
assert.strictEqual(emailMock.callCount(), 1);
|
||||
});
|
||||
|
||||
it('returns 200 for nonexistent email (does not reveal user existence)', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/reset-password')
|
||||
.send({ email: '[email protected]' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.status, 'ok');
|
||||
assert.strictEqual(emailMock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('returns 500 when email is missing', async () => {
|
||||
const res = await request(app).post('/auth/reset-password').send({});
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
assert.strictEqual(res.body.message, 'Email address required.');
|
||||
assert.strictEqual(emailMock.callCount(), 0);
|
||||
});
|
||||
|
||||
it('sets a resetPasswordGuid on the user', async () => {
|
||||
await request(app)
|
||||
.post('/auth/reset-password')
|
||||
.send({ email: '[email protected]' });
|
||||
|
||||
const userRepo = getRepository(User);
|
||||
const user = await userRepo
|
||||
.createQueryBuilder('user')
|
||||
.addSelect(['user.resetPasswordGuid', 'user.recoveryLinkExpirationDate'])
|
||||
.where('user.email = :email', { email: '[email protected]' })
|
||||
.getOneOrFail();
|
||||
|
||||
assert.notStrictEqual(user.resetPasswordGuid, undefined);
|
||||
assert.notStrictEqual(user.resetPasswordGuid, null);
|
||||
assert.notStrictEqual(user.recoveryLinkExpirationDate, undefined);
|
||||
assert.strictEqual(emailMock.callCount(), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/reset-password/:guid', () => {
|
||||
/** Trigger a password reset and return the guid. */
|
||||
async function getResetGuid(email: string): Promise<string> {
|
||||
await request(app).post('/auth/reset-password').send({ email });
|
||||
|
||||
const userRepo = getRepository(User);
|
||||
const user = await userRepo
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.resetPasswordGuid')
|
||||
.where('user.email = :email', { email })
|
||||
.getOneOrFail();
|
||||
|
||||
return user.resetPasswordGuid!;
|
||||
}
|
||||
|
||||
it('resets password with a valid guid and password', async () => {
|
||||
const guid = await getResetGuid('[email protected]');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/auth/reset-password/${guid}`)
|
||||
.send({ password: 'newpassword123' });
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
assert.strictEqual(res.body.status, 'ok');
|
||||
|
||||
// Old password no longer works
|
||||
const oldLogin = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ email: '[email protected]', password: 'test1234' });
|
||||
assert.strictEqual(oldLogin.status, 403);
|
||||
|
||||
// New password works
|
||||
const newLogin = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ email: '[email protected]', password: 'newpassword123' });
|
||||
assert.strictEqual(newLogin.status, 200);
|
||||
});
|
||||
|
||||
it('returns 500 for an invalid guid', async () => {
|
||||
const res = await request(app)
|
||||
.post('/auth/reset-password/invalid-guid-here')
|
||||
.send({ password: 'newpassword123' });
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
assert.strictEqual(res.body.message, 'Invalid password reset link.');
|
||||
});
|
||||
|
||||
it('returns 500 when password is too short', async () => {
|
||||
const guid = await getResetGuid('[email protected]');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/auth/reset-password/${guid}`)
|
||||
.send({ password: 'short' });
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
assert.strictEqual(
|
||||
res.body.message,
|
||||
'Password must be at least 8 characters long.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 500 when password is missing', async () => {
|
||||
const guid = await getResetGuid('[email protected]');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/auth/reset-password/${guid}`)
|
||||
.send({});
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
assert.strictEqual(
|
||||
res.body.message,
|
||||
'Password must be at least 8 characters long.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 500 for an expired recovery link', async () => {
|
||||
const guid = await getResetGuid('[email protected]');
|
||||
|
||||
// Expire the link
|
||||
const userRepo = getRepository(User);
|
||||
const user = await userRepo.findOneOrFail({
|
||||
where: { email: '[email protected]' },
|
||||
});
|
||||
user.recoveryLinkExpirationDate = new Date('2020-01-01');
|
||||
await userRepo.save(user);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/auth/reset-password/${guid}`)
|
||||
.send({ password: 'newpassword123' });
|
||||
|
||||
assert.strictEqual(res.status, 500);
|
||||
assert.strictEqual(res.body.message, 'Invalid password reset link.');
|
||||
});
|
||||
|
||||
it('cannot reuse a guid after successful reset', async () => {
|
||||
const guid = await getResetGuid('[email protected]');
|
||||
|
||||
// First reset succeeds
|
||||
const first = await request(app)
|
||||
.post(`/auth/reset-password/${guid}`)
|
||||
.send({ password: 'newpassword123' });
|
||||
assert.strictEqual(first.status, 200);
|
||||
|
||||
// Second reset with same guid fails (recoveryLinkExpirationDate was cleared)
|
||||
const second = await request(app)
|
||||
.post(`/auth/reset-password/${guid}`)
|
||||
.send({ password: 'anotherpassword' });
|
||||
assert.strictEqual(second.status, 500);
|
||||
});
|
||||
});
|
||||
@@ -738,7 +738,7 @@ authRoutes.post('/reset-password', async (req, res, next) => {
|
||||
|
||||
if (user) {
|
||||
await user.resetPassword();
|
||||
userRepository.save(user);
|
||||
await userRepository.save(user);
|
||||
logger.info('Successfully sent password reset link', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
@@ -803,7 +803,7 @@ authRoutes.post('/reset-password/:guid', async (req, res, next) => {
|
||||
}
|
||||
user.recoveryLinkExpirationDate = null;
|
||||
await user.setPassword(req.body.password);
|
||||
userRepository.save(user);
|
||||
await userRepository.save(user);
|
||||
logger.info('Successfully reset password', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { UserType } from '@server/constants/user';
|
||||
import dataSource, { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import { seedTestDb } from '@server/utils/seedTestDb';
|
||||
import { copyFileSync } from 'fs';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import path from 'path';
|
||||
|
||||
const prepareDb = async () => {
|
||||
@@ -12,61 +9,10 @@ const prepareDb = async () => {
|
||||
path.join(__dirname, '../../config/settings.json')
|
||||
);
|
||||
|
||||
// Connect to DB and seed test data
|
||||
const dbConnection = await dataSource.initialize();
|
||||
|
||||
if (process.env.PRESERVE_DB !== 'true') {
|
||||
await dbConnection.dropDatabase();
|
||||
}
|
||||
|
||||
// Run migrations in production
|
||||
if (process.env.WITH_MIGRATIONS === 'true') {
|
||||
await dbConnection.runMigrations();
|
||||
} else {
|
||||
await dbConnection.synchronize();
|
||||
}
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const admin = await userRepository.findOne({
|
||||
select: { id: true, plexId: true },
|
||||
where: { id: 1 },
|
||||
await seedTestDb({
|
||||
preserveDb: process.env.PRESERVE_DB === 'true',
|
||||
withMigrations: process.env.WITH_MIGRATIONS === 'true',
|
||||
});
|
||||
|
||||
// Create the admin user
|
||||
const user =
|
||||
(await userRepository.findOne({
|
||||
where: { email: '[email protected]' },
|
||||
})) ?? new User();
|
||||
user.plexId = admin?.plexId ?? 1;
|
||||
user.plexToken = '1234';
|
||||
user.plexUsername = 'admin';
|
||||
user.username = 'admin';
|
||||
user.email = '[email protected]';
|
||||
user.userType = UserType.PLEX;
|
||||
await user.setPassword('test1234');
|
||||
user.permissions = 2;
|
||||
user.avatar = gravatarUrl('[email protected]', { default: 'mm', size: 200 });
|
||||
await userRepository.save(user);
|
||||
|
||||
// Create the other user
|
||||
const otherUser =
|
||||
(await userRepository.findOne({
|
||||
where: { email: '[email protected]' },
|
||||
})) ?? new User();
|
||||
otherUser.plexId = admin?.plexId ?? 1;
|
||||
otherUser.plexToken = '1234';
|
||||
otherUser.plexUsername = 'friend';
|
||||
otherUser.username = 'friend';
|
||||
otherUser.email = '[email protected]';
|
||||
otherUser.userType = UserType.PLEX;
|
||||
await otherUser.setPassword('test1234');
|
||||
otherUser.permissions = 32;
|
||||
otherUser.avatar = gravatarUrl('[email protected]', {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
});
|
||||
await userRepository.save(otherUser);
|
||||
};
|
||||
|
||||
prepareDb();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { resetTestDb, seedTestDb } from '@server/utils/seedTestDb';
|
||||
import { before, beforeEach } from 'node:test';
|
||||
|
||||
export function setupTestDb() {
|
||||
before(async () => {
|
||||
await seedTestDb();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await resetTestDb();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Runs unit tests using the `node:test` runner.
|
||||
|
||||
import { Command, Option } from 'commander';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { glob } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { run } from 'node:test';
|
||||
import * as reporters from 'node:test/reporters';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const resolveImport = (specifier: string) =>
|
||||
fileURLToPath(import.meta.resolve(specifier));
|
||||
const BASE_DIR = join(import.meta.dirname, '../..');
|
||||
|
||||
const program = new Command();
|
||||
program
|
||||
.name('test')
|
||||
.argument('[file...]', 'Test file(s) to run (default: all)')
|
||||
.option(
|
||||
'-m, --test-name-pattern <pattern>',
|
||||
'Run tests matching the given pattern',
|
||||
(v, acc: string[]) => [...acc, v],
|
||||
[] as string[]
|
||||
)
|
||||
.option(
|
||||
'--test-reporter <reporter>',
|
||||
'Test reporter to use (repeatable)',
|
||||
(v, acc: string[]) => [...acc, v],
|
||||
[] as string[]
|
||||
)
|
||||
.option(
|
||||
'--test-reporter-destination <dest>',
|
||||
'Test reporter destination: stdout, stderr, or a file path (repeatable)',
|
||||
(v, acc: string[]) => [...acc, v],
|
||||
[] as string[]
|
||||
)
|
||||
.option(
|
||||
'--coverage, --experimental-test-coverage',
|
||||
'Enable code coverage collection'
|
||||
)
|
||||
// ignore additional options passed by vscode test runner
|
||||
.addOption(new Option('--test').hideHelp())
|
||||
.parse();
|
||||
|
||||
const positionals: string[] = program.args;
|
||||
const opts = program.opts<{
|
||||
testNamePattern: string[];
|
||||
testReporter: string[];
|
||||
testReporterDestination: string[];
|
||||
experimentalTestCoverage: boolean;
|
||||
}>();
|
||||
|
||||
let files: string[];
|
||||
|
||||
if (positionals.length > 0) {
|
||||
files = positionals.map((f) => resolve(f));
|
||||
} else {
|
||||
files = [];
|
||||
for await (const entry of glob(join(BASE_DIR, 'server/**/*.test.ts'))) {
|
||||
files.push(resolve(entry));
|
||||
}
|
||||
files.sort();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
process.env.NODE_ENV = 'test';
|
||||
// configure ts
|
||||
process.env.TS_NODE_PROJECT = resolveImport('../tsconfig.json');
|
||||
process.env.TS_NODE_FILES = 'true';
|
||||
|
||||
const stream = run({
|
||||
files,
|
||||
execArgv: [
|
||||
'--experimental-test-module-mocks',
|
||||
'-r',
|
||||
'ts-node/register',
|
||||
'-r',
|
||||
'tsconfig-paths/register',
|
||||
'-r',
|
||||
resolveImport('./setup.ts'),
|
||||
],
|
||||
coverage: opts.experimentalTestCoverage,
|
||||
coverageExcludeGlobs: [
|
||||
join(BASE_DIR, 'server/test/**'),
|
||||
join(BASE_DIR, 'server/migration/**'),
|
||||
],
|
||||
testNamePatterns: opts.testNamePattern,
|
||||
});
|
||||
|
||||
// In CI, write a JUnit report to a file for use by GitHub
|
||||
if (process.env.CI) {
|
||||
const reportStream = createWriteStream(join(BASE_DIR, 'report.xml'));
|
||||
stream.compose(reporters.junit).pipe(reportStream);
|
||||
}
|
||||
|
||||
if (opts.testReporter.length > 0) {
|
||||
for (let i = 0; i < opts.testReporter.length; i++) {
|
||||
const reporterName = opts.testReporter[i];
|
||||
// check built-in reporters, otherwise import
|
||||
const reporter =
|
||||
reporterName in reporters
|
||||
? reporters[reporterName as keyof typeof reporters]
|
||||
: await import(reporterName).then((m) => m.default);
|
||||
|
||||
if (reporter == null) {
|
||||
console.error('Invalid test reporter: ', reporterName);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const destArg = opts.testReporterDestination[i];
|
||||
const dest =
|
||||
destArg === 'stdout' || destArg == null
|
||||
? process.stdout
|
||||
: destArg === 'stderr'
|
||||
? process.stderr
|
||||
: createWriteStream(destArg);
|
||||
|
||||
stream.compose(reporter).pipe(dest);
|
||||
}
|
||||
} else {
|
||||
stream.compose(reporters.spec).pipe(process.stdout);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import logger from '@server/logger';
|
||||
import { after, before } from 'node:test';
|
||||
|
||||
before(() => {
|
||||
if (process.env.VERBOSE != 'true') logger.silent = true;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (process.env.VERBOSE != 'true') logger.silent = false;
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { UserType } from '@server/constants/user';
|
||||
import dataSource, { getRepository } from '@server/datasource';
|
||||
import { User } from '@server/entity/User';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
|
||||
export interface SeedDbOptions {
|
||||
/** If true, preserves existing data instead of dropping the database */
|
||||
preserveDb?: boolean;
|
||||
/** If true, runs migrations instead of synchronizing schema */
|
||||
withMigrations?: boolean;
|
||||
}
|
||||
|
||||
// Precomputed bcrypt hash of 'test1234'. We precompute this to avoid
|
||||
// having to hash the password every time we seed the database.
|
||||
const TEST_USER_PASSWORD_HASH =
|
||||
'$2b$12$Z5V2P5HZgmx4/AnWFMZN1.aD5AM1NucNi.mhNTSQ9oVtmdzu7Le/a';
|
||||
|
||||
/**
|
||||
* Seeds test users into the database.
|
||||
* Assumes the database schema is already set up.
|
||||
*/
|
||||
async function seedTestUsers(): Promise<void> {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const admin = await userRepository.findOne({
|
||||
select: { id: true, plexId: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
// Create the admin user
|
||||
const user =
|
||||
(await userRepository.findOne({
|
||||
where: { email: '[email protected]' },
|
||||
})) ?? new User();
|
||||
user.plexId = admin?.plexId ?? 1;
|
||||
user.plexToken = '1234';
|
||||
user.plexUsername = 'admin';
|
||||
user.username = 'admin';
|
||||
user.email = '[email protected]';
|
||||
user.userType = UserType.PLEX;
|
||||
user.password = TEST_USER_PASSWORD_HASH;
|
||||
user.permissions = 2;
|
||||
user.avatar = gravatarUrl('[email protected]', { default: 'mm', size: 200 });
|
||||
await userRepository.save(user);
|
||||
|
||||
// Create the other user
|
||||
const otherUser =
|
||||
(await userRepository.findOne({
|
||||
where: { email: '[email protected]' },
|
||||
})) ?? new User();
|
||||
otherUser.plexId = admin?.plexId ?? 1;
|
||||
otherUser.plexToken = '1234';
|
||||
otherUser.plexUsername = 'friend';
|
||||
otherUser.username = 'friend';
|
||||
otherUser.email = '[email protected]';
|
||||
otherUser.userType = UserType.PLEX;
|
||||
otherUser.password = TEST_USER_PASSWORD_HASH;
|
||||
otherUser.permissions = 32;
|
||||
otherUser.avatar = gravatarUrl('[email protected]', {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
});
|
||||
await userRepository.save(otherUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the database connection and seeds test users.
|
||||
* Used by both Cypress tests and Vitest unit tests.
|
||||
*/
|
||||
export async function seedTestDb(options: SeedDbOptions = {}): Promise<void> {
|
||||
const dbConnection = dataSource.isInitialized
|
||||
? dataSource
|
||||
: await dataSource.initialize();
|
||||
|
||||
if (!options.preserveDb) {
|
||||
await dbConnection.dropDatabase();
|
||||
}
|
||||
|
||||
if (options.withMigrations) {
|
||||
await dbConnection.runMigrations();
|
||||
} else {
|
||||
await dbConnection.synchronize();
|
||||
}
|
||||
|
||||
await seedTestUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the database to a clean state with seeded test users.
|
||||
* Used between tests to ensure isolation.
|
||||
* Assumes DB has been initialized.
|
||||
*/
|
||||
export async function resetTestDb(): Promise<void> {
|
||||
await dataSource.synchronize(true);
|
||||
await seedTestUsers();
|
||||
}
|
||||
Reference in New Issue
Block a user