test: support server-side unit testing (#2485)
This commit is contained in:
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -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'
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
lcov.info
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
@@ -16,6 +16,7 @@
|
||||
|
||||
"stylelint.vscode-stylelint",
|
||||
|
||||
"bradlc.vscode-tailwindcss"
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"firsttris.vscode-jest-runner"
|
||||
]
|
||||
}
|
||||
|
||||
9
.vscode/settings.json
vendored
9
.vscode/settings.json
vendored
@@ -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",
|
||||
|
||||
1598
pnpm-lock.yaml
generated
1598
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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"`);
|
||||
}
|
||||
}
|
||||
397
server/routes/auth.test.ts
Normal file
397
server/routes/auth.test.ts
Normal file
@@ -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('admin@seerr.dev', '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: 'admin@seerr.dev' },
|
||||
});
|
||||
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: 'admin@seerr.dev', 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: 'admin@seerr.dev', 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: 'nobody@seerr.dev', 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: 'admin@seerr.dev', 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: 'admin@seerr.dev' });
|
||||
|
||||
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: 'Admin@Seerr.Dev', 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: 'friend@seerr.dev', 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: 'admin@seerr.dev', 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('admin@seerr.dev', '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: 'admin@seerr.dev' });
|
||||
|
||||
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: 'nonexistent@seerr.dev' });
|
||||
|
||||
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: 'admin@seerr.dev' });
|
||||
|
||||
const userRepo = getRepository(User);
|
||||
const user = await userRepo
|
||||
.createQueryBuilder('user')
|
||||
.addSelect(['user.resetPasswordGuid', 'user.recoveryLinkExpirationDate'])
|
||||
.where('user.email = :email', { email: 'admin@seerr.dev' })
|
||||
.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('admin@seerr.dev');
|
||||
|
||||
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: 'admin@seerr.dev', password: 'test1234' });
|
||||
assert.strictEqual(oldLogin.status, 403);
|
||||
|
||||
// New password works
|
||||
const newLogin = await request(app)
|
||||
.post('/auth/local')
|
||||
.send({ email: 'admin@seerr.dev', 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('admin@seerr.dev');
|
||||
|
||||
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('admin@seerr.dev');
|
||||
|
||||
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('admin@seerr.dev');
|
||||
|
||||
// Expire the link
|
||||
const userRepo = getRepository(User);
|
||||
const user = await userRepo.findOneOrFail({
|
||||
where: { email: 'admin@seerr.dev' },
|
||||
});
|
||||
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('admin@seerr.dev');
|
||||
|
||||
// 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: 'admin@seerr.dev' },
|
||||
})) ?? new User();
|
||||
user.plexId = admin?.plexId ?? 1;
|
||||
user.plexToken = '1234';
|
||||
user.plexUsername = 'admin';
|
||||
user.username = 'admin';
|
||||
user.email = 'admin@seerr.dev';
|
||||
user.userType = UserType.PLEX;
|
||||
await user.setPassword('test1234');
|
||||
user.permissions = 2;
|
||||
user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 });
|
||||
await userRepository.save(user);
|
||||
|
||||
// Create the other user
|
||||
const otherUser =
|
||||
(await userRepository.findOne({
|
||||
where: { email: 'friend@seerr.dev' },
|
||||
})) ?? new User();
|
||||
otherUser.plexId = admin?.plexId ?? 1;
|
||||
otherUser.plexToken = '1234';
|
||||
otherUser.plexUsername = 'friend';
|
||||
otherUser.username = 'friend';
|
||||
otherUser.email = 'friend@seerr.dev';
|
||||
otherUser.userType = UserType.PLEX;
|
||||
await otherUser.setPassword('test1234');
|
||||
otherUser.permissions = 32;
|
||||
otherUser.avatar = gravatarUrl('friend@seerr.dev', {
|
||||
default: 'mm',
|
||||
size: 200,
|
||||
});
|
||||
await userRepository.save(otherUser);
|
||||
};
|
||||
|
||||
prepareDb();
|
||||
|
||||
11
server/test/db.ts
Normal file
11
server/test/db.ts
Normal file
@@ -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();
|
||||
});
|
||||
}
|
||||
122
server/test/index.mts
Normal file
122
server/test/index.mts
Normal file
@@ -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);
|
||||
}
|
||||
10
server/test/setup.ts
Normal file
10
server/test/setup.ts
Normal file
@@ -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;
|
||||
});
|
||||
96
server/utils/seedTestDb.ts
Normal file
96
server/utils/seedTestDb.ts
Normal file
@@ -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: 'admin@seerr.dev' },
|
||||
})) ?? new User();
|
||||
user.plexId = admin?.plexId ?? 1;
|
||||
user.plexToken = '1234';
|
||||
user.plexUsername = 'admin';
|
||||
user.username = 'admin';
|
||||
user.email = 'admin@seerr.dev';
|
||||
user.userType = UserType.PLEX;
|
||||
user.password = TEST_USER_PASSWORD_HASH;
|
||||
user.permissions = 2;
|
||||
user.avatar = gravatarUrl('admin@seerr.dev', { default: 'mm', size: 200 });
|
||||
await userRepository.save(user);
|
||||
|
||||
// Create the other user
|
||||
const otherUser =
|
||||
(await userRepository.findOne({
|
||||
where: { email: 'friend@seerr.dev' },
|
||||
})) ?? new User();
|
||||
otherUser.plexId = admin?.plexId ?? 1;
|
||||
otherUser.plexToken = '1234';
|
||||
otherUser.plexUsername = 'friend';
|
||||
otherUser.username = 'friend';
|
||||
otherUser.email = 'friend@seerr.dev';
|
||||
otherUser.userType = UserType.PLEX;
|
||||
otherUser.password = TEST_USER_PASSWORD_HASH;
|
||||
otherUser.permissions = 32;
|
||||
otherUser.avatar = gravatarUrl('friend@seerr.dev', {
|
||||
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