Add unit tests for models and services
- Implemented unit tests for the Package model, covering methods such as generateId, findById, findByName, and version management. - Created unit tests for the Repository model, including repository creation, name validation, and retrieval methods. - Added tests for the Session model, focusing on session creation, validation, and invalidation. - Developed unit tests for the User model, ensuring user creation, password hashing, and retrieval methods function correctly. - Implemented AuthService tests, validating login, token refresh, and session management. - Added TokenService tests, covering token creation, validation, and revocation processes.
This commit is contained in:
232
test/unit/models/apitoken.test.ts
Normal file
232
test/unit/models/apitoken.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* ApiToken model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
||||
|
||||
describe('ApiToken Model', () => {
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
async function createToken(overrides: Partial<ApiToken> = {}): Promise<ApiToken> {
|
||||
const token = new ApiToken();
|
||||
token.id = await ApiToken.getNewId();
|
||||
token.userId = overrides.userId || testUserId;
|
||||
token.name = overrides.name || 'test-token';
|
||||
token.tokenHash = overrides.tokenHash || `hash-${crypto.randomUUID()}`;
|
||||
token.tokenPrefix = overrides.tokenPrefix || 'srg_test';
|
||||
token.protocols = overrides.protocols || ['npm', 'oci'];
|
||||
token.scopes = overrides.scopes || [{ protocol: '*', actions: ['read', 'write'] }];
|
||||
token.createdAt = new Date();
|
||||
|
||||
if (overrides.expiresAt) token.expiresAt = overrides.expiresAt;
|
||||
if (overrides.isRevoked) token.isRevoked = overrides.isRevoked;
|
||||
if (overrides.organizationId) token.organizationId = overrides.organizationId;
|
||||
|
||||
await token.save();
|
||||
return token;
|
||||
}
|
||||
|
||||
describe('findByHash', () => {
|
||||
it('should find token by hash', async () => {
|
||||
const created = await createToken({ tokenHash: 'unique-hash-123' });
|
||||
|
||||
const found = await ApiToken.findByHash('unique-hash-123');
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
|
||||
it('should not find revoked tokens', async () => {
|
||||
await createToken({
|
||||
tokenHash: 'revoked-hash',
|
||||
isRevoked: true,
|
||||
});
|
||||
|
||||
const found = await ApiToken.findByHash('revoked-hash');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserTokens', () => {
|
||||
it('should return all user tokens', async () => {
|
||||
await createToken({ name: 'token1' });
|
||||
await createToken({ name: 'token2' });
|
||||
|
||||
const tokens = await ApiToken.getUserTokens(testUserId);
|
||||
assertEquals(tokens.length, 2);
|
||||
});
|
||||
|
||||
it('should not return revoked tokens', async () => {
|
||||
await createToken({ name: 'active' });
|
||||
await createToken({ name: 'revoked', isRevoked: true });
|
||||
|
||||
const tokens = await ApiToken.getUserTokens(testUserId);
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].name, 'active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgTokens', () => {
|
||||
it('should return organization tokens', async () => {
|
||||
const orgId = 'org-123';
|
||||
await createToken({ name: 'org-token', organizationId: orgId });
|
||||
await createToken({ name: 'personal-token' }); // No org
|
||||
|
||||
const tokens = await ApiToken.getOrgTokens(orgId);
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].name, 'org-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValid', () => {
|
||||
it('should return true for valid token', async () => {
|
||||
const token = await createToken();
|
||||
assertEquals(token.isValid(), true);
|
||||
});
|
||||
|
||||
it('should return false for revoked token', async () => {
|
||||
const token = await createToken({ isRevoked: true });
|
||||
assertEquals(token.isValid(), false);
|
||||
});
|
||||
|
||||
it('should return false for expired token', async () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - 1);
|
||||
|
||||
const token = await createToken({ expiresAt: pastDate });
|
||||
assertEquals(token.isValid(), false);
|
||||
});
|
||||
|
||||
it('should return true for non-expired token', async () => {
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 30);
|
||||
|
||||
const token = await createToken({ expiresAt: futureDate });
|
||||
assertEquals(token.isValid(), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordUsage', () => {
|
||||
it('should update usage stats', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.recordUsage('192.168.1.1');
|
||||
|
||||
assertExists(token.lastUsedAt);
|
||||
assertEquals(token.lastUsedIp, '192.168.1.1');
|
||||
assertEquals(token.usageCount, 1);
|
||||
});
|
||||
|
||||
it('should increment usage count', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.recordUsage();
|
||||
await token.recordUsage();
|
||||
await token.recordUsage();
|
||||
|
||||
assertEquals(token.usageCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revoke', () => {
|
||||
it('should revoke token with reason', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.revoke('Security concern');
|
||||
|
||||
assertEquals(token.isRevoked, true);
|
||||
assertExists(token.revokedAt);
|
||||
assertEquals(token.revokedReason, 'Security concern');
|
||||
});
|
||||
|
||||
it('should revoke token without reason', async () => {
|
||||
const token = await createToken();
|
||||
|
||||
await token.revoke();
|
||||
|
||||
assertEquals(token.isRevoked, true);
|
||||
assertExists(token.revokedAt);
|
||||
assertEquals(token.revokedReason, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasProtocol', () => {
|
||||
it('should return true for allowed protocol', async () => {
|
||||
const token = await createToken({ protocols: ['npm', 'oci'] });
|
||||
|
||||
assertEquals(token.hasProtocol('npm'), true);
|
||||
assertEquals(token.hasProtocol('oci'), true);
|
||||
});
|
||||
|
||||
it('should return false for disallowed protocol', async () => {
|
||||
const token = await createToken({ protocols: ['npm'] });
|
||||
|
||||
assertEquals(token.hasProtocol('maven'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasScope', () => {
|
||||
it('should allow wildcard protocol scope', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', actions: ['read', 'write'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm'), true);
|
||||
assertEquals(token.hasScope('oci'), true);
|
||||
assertEquals(token.hasScope('maven'), true);
|
||||
});
|
||||
|
||||
it('should restrict by specific protocol', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm'), true);
|
||||
assertEquals(token.hasScope('oci'), false);
|
||||
});
|
||||
|
||||
it('should restrict by organization', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', organizationId: 'org-123', actions: ['read'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm', 'org-123'), true);
|
||||
assertEquals(token.hasScope('npm', 'org-456'), false);
|
||||
});
|
||||
|
||||
it('should check action permissions', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', actions: ['read'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'read'), true);
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'write'), false);
|
||||
});
|
||||
|
||||
it('should allow wildcard action', async () => {
|
||||
const token = await createToken({
|
||||
scopes: [{ protocol: '*', actions: ['*'] }],
|
||||
});
|
||||
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'read'), true);
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'write'), true);
|
||||
assertEquals(token.hasScope('npm', undefined, undefined, 'delete'), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
220
test/unit/models/organization.test.ts
Normal file
220
test/unit/models/organization.test.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Organization model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { Organization } from '../../../ts/models/organization.ts';
|
||||
|
||||
describe('Organization Model', () => {
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
describe('createOrganization', () => {
|
||||
it('should create an organization with valid data', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'test-org',
|
||||
displayName: 'Test Organization',
|
||||
description: 'A test organization',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertExists(org.id);
|
||||
assertEquals(org.name, 'test-org');
|
||||
assertEquals(org.displayName, 'Test Organization');
|
||||
assertEquals(org.description, 'A test organization');
|
||||
assertEquals(org.createdById, testUserId);
|
||||
assertEquals(org.isPublic, false);
|
||||
assertEquals(org.memberCount, 0);
|
||||
assertEquals(org.plan, 'free');
|
||||
});
|
||||
|
||||
it('should allow dots in org name (domain-like)', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'push.rocks',
|
||||
displayName: 'Push Rocks',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.name, 'push.rocks');
|
||||
});
|
||||
|
||||
it('should allow hyphens in org name', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'my-awesome-org',
|
||||
displayName: 'My Awesome Org',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.name, 'my-awesome-org');
|
||||
});
|
||||
|
||||
it('should reject uppercase names (must be lowercase)', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'UPPERCASE',
|
||||
displayName: 'Uppercase Org',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid names starting with dot', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: '.invalid',
|
||||
displayName: 'Invalid',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject invalid names ending with dot', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'invalid.',
|
||||
displayName: 'Invalid',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject names with special characters', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'invalid@org',
|
||||
displayName: 'Invalid',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should set default settings', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'defaults',
|
||||
displayName: 'Defaults Test',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.settings.requireMfa, false);
|
||||
assertEquals(org.settings.allowPublicRepositories, true);
|
||||
assertEquals(org.settings.defaultRepositoryVisibility, 'private');
|
||||
assertEquals(org.settings.allowedProtocols.length, 7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find organization by ID', async () => {
|
||||
const created = await Organization.createOrganization({
|
||||
name: 'findable',
|
||||
displayName: 'Findable Org',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Organization.findById(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
|
||||
it('should return null for non-existent ID', async () => {
|
||||
const found = await Organization.findById('non-existent-id');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find organization by name (case-insensitive)', async () => {
|
||||
await Organization.createOrganization({
|
||||
name: 'byname',
|
||||
displayName: 'By Name',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Organization.findByName('BYNAME');
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'byname');
|
||||
});
|
||||
});
|
||||
|
||||
describe('storage quota', () => {
|
||||
it('should have default 5GB quota', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'quota-test',
|
||||
displayName: 'Quota Test',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.storageQuotaBytes, 5 * 1024 * 1024 * 1024);
|
||||
assertEquals(org.usedStorageBytes, 0);
|
||||
});
|
||||
|
||||
it('should check available storage', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'storage-check',
|
||||
displayName: 'Storage Check',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(org.hasStorageAvailable(1024), true);
|
||||
assertEquals(org.hasStorageAvailable(6 * 1024 * 1024 * 1024), false);
|
||||
});
|
||||
|
||||
it('should allow unlimited storage with -1 quota', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'unlimited',
|
||||
displayName: 'Unlimited',
|
||||
createdById: testUserId,
|
||||
});
|
||||
org.storageQuotaBytes = -1;
|
||||
|
||||
assertEquals(org.hasStorageAvailable(1000 * 1024 * 1024 * 1024), true);
|
||||
});
|
||||
|
||||
it('should update storage usage', async () => {
|
||||
const org = await Organization.createOrganization({
|
||||
name: 'usage-test',
|
||||
displayName: 'Usage Test',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
await org.updateStorageUsage(1000);
|
||||
assertEquals(org.usedStorageBytes, 1000);
|
||||
|
||||
await org.updateStorageUsage(500);
|
||||
assertEquals(org.usedStorageBytes, 1500);
|
||||
|
||||
await org.updateStorageUsage(-2000);
|
||||
assertEquals(org.usedStorageBytes, 0); // Should not go negative
|
||||
});
|
||||
});
|
||||
});
|
||||
240
test/unit/models/package.test.ts
Normal file
240
test/unit/models/package.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Package model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
createTestRepository,
|
||||
} from '../../helpers/index.ts';
|
||||
import { Package } from '../../../ts/models/package.ts';
|
||||
import type { IPackageVersion } from '../../../ts/interfaces/package.interfaces.ts';
|
||||
|
||||
describe('Package Model', () => {
|
||||
let testUserId: string;
|
||||
let testOrgId: string;
|
||||
let testRepoId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
const { organization } = await createOrgWithOwner(testUserId);
|
||||
testOrgId = organization.id;
|
||||
const repo = await createTestRepository({
|
||||
organizationId: testOrgId,
|
||||
createdById: testUserId,
|
||||
protocol: 'npm',
|
||||
});
|
||||
testRepoId = repo.id;
|
||||
});
|
||||
|
||||
function createVersion(version: string): IPackageVersion {
|
||||
return {
|
||||
version,
|
||||
publishedAt: new Date(),
|
||||
publishedBy: testUserId,
|
||||
size: 1024,
|
||||
checksum: `sha256-${crypto.randomUUID()}`,
|
||||
checksumAlgorithm: 'sha256',
|
||||
downloads: 0,
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
async function createPackage(name: string, versions: string[] = ['1.0.0']): Promise<Package> {
|
||||
const pkg = new Package();
|
||||
pkg.id = Package.generateId('npm', testOrgId, name);
|
||||
pkg.organizationId = testOrgId;
|
||||
pkg.repositoryId = testRepoId;
|
||||
pkg.protocol = 'npm';
|
||||
pkg.name = name;
|
||||
pkg.createdById = testUserId;
|
||||
pkg.createdAt = new Date();
|
||||
pkg.updatedAt = new Date();
|
||||
|
||||
for (const v of versions) {
|
||||
pkg.addVersion(createVersion(v));
|
||||
}
|
||||
pkg.distTags['latest'] = versions[versions.length - 1];
|
||||
|
||||
await pkg.save();
|
||||
return pkg;
|
||||
}
|
||||
|
||||
describe('generateId', () => {
|
||||
it('should generate correct format', () => {
|
||||
const id = Package.generateId('npm', 'my-org', 'my-package');
|
||||
assertEquals(id, 'npm:my-org:my-package');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find package by ID', async () => {
|
||||
const created = await createPackage('findable');
|
||||
|
||||
const found = await Package.findById(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'findable');
|
||||
});
|
||||
|
||||
it('should return null for non-existent ID', async () => {
|
||||
const found = await Package.findById('npm:fake:package');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find package by protocol, org, and name', async () => {
|
||||
await createPackage('by-name');
|
||||
|
||||
const found = await Package.findByName('npm', testOrgId, 'by-name');
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'by-name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgPackages', () => {
|
||||
it('should return all packages in organization', async () => {
|
||||
await createPackage('pkg1');
|
||||
await createPackage('pkg2');
|
||||
await createPackage('pkg3');
|
||||
|
||||
const packages = await Package.getOrgPackages(testOrgId);
|
||||
assertEquals(packages.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should find packages by name', async () => {
|
||||
await createPackage('search-me');
|
||||
await createPackage('find-this');
|
||||
await createPackage('other');
|
||||
|
||||
const results = await Package.search('search');
|
||||
assertEquals(results.length, 1);
|
||||
assertEquals(results[0].name, 'search-me');
|
||||
});
|
||||
|
||||
it('should find packages by description', async () => {
|
||||
const pkg = await createPackage('described');
|
||||
pkg.description = 'A unique description for testing';
|
||||
await pkg.save();
|
||||
|
||||
const results = await Package.search('unique description');
|
||||
assertEquals(results.length, 1);
|
||||
});
|
||||
|
||||
it('should filter by protocol', async () => {
|
||||
await createPackage('npm-pkg');
|
||||
|
||||
const results = await Package.search('npm', { protocol: 'oci' });
|
||||
assertEquals(results.length, 0);
|
||||
});
|
||||
|
||||
it('should apply pagination', async () => {
|
||||
await createPackage('page1');
|
||||
await createPackage('page2');
|
||||
await createPackage('page3');
|
||||
|
||||
const firstPage = await Package.search('page', { limit: 2, offset: 0 });
|
||||
assertEquals(firstPage.length, 2);
|
||||
|
||||
const secondPage = await Package.search('page', { limit: 2, offset: 2 });
|
||||
assertEquals(secondPage.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('versions', () => {
|
||||
it('should add version and update storage', async () => {
|
||||
const pkg = await createPackage('versioned', []);
|
||||
|
||||
pkg.addVersion(createVersion('1.0.0'));
|
||||
|
||||
assertEquals(Object.keys(pkg.versions).length, 1);
|
||||
assertEquals(pkg.storageBytes, 1024);
|
||||
});
|
||||
|
||||
it('should get specific version', async () => {
|
||||
const pkg = await createPackage('multi-version', ['1.0.0', '1.1.0', '2.0.0']);
|
||||
|
||||
const v1 = pkg.getVersion('1.0.0');
|
||||
assertExists(v1);
|
||||
assertEquals(v1.version, '1.0.0');
|
||||
|
||||
const v2 = pkg.getVersion('2.0.0');
|
||||
assertExists(v2);
|
||||
assertEquals(v2.version, '2.0.0');
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent version', async () => {
|
||||
const pkg = await createPackage('single', ['1.0.0']);
|
||||
|
||||
const missing = pkg.getVersion('9.9.9');
|
||||
assertEquals(missing, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestVersion', () => {
|
||||
it('should return version from distTags.latest', async () => {
|
||||
const pkg = await createPackage('tagged', ['1.0.0', '2.0.0']);
|
||||
pkg.distTags['latest'] = '1.0.0'; // Set older version as latest
|
||||
await pkg.save();
|
||||
|
||||
const latest = pkg.getLatestVersion();
|
||||
assertExists(latest);
|
||||
assertEquals(latest.version, '1.0.0');
|
||||
});
|
||||
|
||||
it('should fallback to last version if no latest tag', async () => {
|
||||
const pkg = await createPackage('untagged', ['1.0.0', '2.0.0']);
|
||||
delete pkg.distTags['latest'];
|
||||
|
||||
const latest = pkg.getLatestVersion();
|
||||
assertExists(latest);
|
||||
assertEquals(latest.version, '2.0.0');
|
||||
});
|
||||
|
||||
it('should return undefined for empty versions', async () => {
|
||||
const pkg = await createPackage('empty', []);
|
||||
delete pkg.distTags['latest'];
|
||||
|
||||
const latest = pkg.getLatestVersion();
|
||||
assertEquals(latest, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementDownloads', () => {
|
||||
it('should increment total download count', async () => {
|
||||
const pkg = await createPackage('downloads');
|
||||
|
||||
await pkg.incrementDownloads();
|
||||
assertEquals(pkg.downloadCount, 1);
|
||||
|
||||
await pkg.incrementDownloads();
|
||||
await pkg.incrementDownloads();
|
||||
assertEquals(pkg.downloadCount, 3);
|
||||
});
|
||||
|
||||
it('should increment version-specific downloads', async () => {
|
||||
const pkg = await createPackage('version-downloads', ['1.0.0', '2.0.0']);
|
||||
|
||||
await pkg.incrementDownloads('1.0.0');
|
||||
assertEquals(pkg.versions['1.0.0'].downloads, 1);
|
||||
assertEquals(pkg.versions['2.0.0'].downloads, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
285
test/unit/models/repository.test.ts
Normal file
285
test/unit/models/repository.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Repository model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import {
|
||||
setupTestDb,
|
||||
teardownTestDb,
|
||||
cleanupTestDb,
|
||||
createTestUser,
|
||||
createOrgWithOwner,
|
||||
} from '../../helpers/index.ts';
|
||||
import { Repository } from '../../../ts/models/repository.ts';
|
||||
|
||||
describe('Repository Model', () => {
|
||||
let testUserId: string;
|
||||
let testOrgId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
const { organization } = await createOrgWithOwner(testUserId);
|
||||
testOrgId = organization.id;
|
||||
});
|
||||
|
||||
describe('createRepository', () => {
|
||||
it('should create a repository with valid data', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'test-repo',
|
||||
description: 'A test repository',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertExists(repo.id);
|
||||
assertEquals(repo.name, 'test-repo');
|
||||
assertEquals(repo.organizationId, testOrgId);
|
||||
assertEquals(repo.protocol, 'npm');
|
||||
assertEquals(repo.visibility, 'private');
|
||||
assertEquals(repo.downloadCount, 0);
|
||||
assertEquals(repo.starCount, 0);
|
||||
});
|
||||
|
||||
it('should allow dots and underscores in name', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'my.test_repo',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.name, 'my.test_repo');
|
||||
});
|
||||
|
||||
it('should lowercase the name', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'UPPERCASE',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.name, 'uppercase');
|
||||
});
|
||||
|
||||
it('should set correct storage namespace', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'packages',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.storageNamespace, `npm/${testOrgId}/packages`);
|
||||
});
|
||||
|
||||
it('should reject duplicate name+protocol in same org', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'unique',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'unique',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'already exists'
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow same name with different protocol', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'packages',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const ociRepo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'packages',
|
||||
protocol: 'oci',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(ociRepo.name, 'packages');
|
||||
assertEquals(ociRepo.protocol, 'oci');
|
||||
});
|
||||
|
||||
it('should reject invalid names', async () => {
|
||||
await assertRejects(
|
||||
async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: '-invalid',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
},
|
||||
Error,
|
||||
'lowercase alphanumeric'
|
||||
);
|
||||
});
|
||||
|
||||
it('should set visibility when provided', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'public-repo',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
assertEquals(repo.visibility, 'public');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find repository by org, name, and protocol', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'findable',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Repository.findByName(testOrgId, 'FINDABLE', 'npm');
|
||||
assertExists(found);
|
||||
assertEquals(found.name, 'findable');
|
||||
});
|
||||
|
||||
it('should return null for wrong protocol', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'npm-only',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const found = await Repository.findByName(testOrgId, 'npm-only', 'oci');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgRepositories', () => {
|
||||
it('should return all org repositories', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'repo1',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'repo2',
|
||||
protocol: 'oci',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'repo3',
|
||||
protocol: 'maven',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const repos = await Repository.getOrgRepositories(testOrgId);
|
||||
assertEquals(repos.length, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublicRepositories', () => {
|
||||
it('should return only public repositories', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'public1',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'private1',
|
||||
protocol: 'npm',
|
||||
visibility: 'private',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const repos = await Repository.getPublicRepositories();
|
||||
assertEquals(repos.length, 1);
|
||||
assertEquals(repos[0].name, 'public1');
|
||||
});
|
||||
|
||||
it('should filter by protocol when provided', async () => {
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'npm-public',
|
||||
protocol: 'npm',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'oci-public',
|
||||
protocol: 'oci',
|
||||
visibility: 'public',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const repos = await Repository.getPublicRepositories('npm');
|
||||
assertEquals(repos.length, 1);
|
||||
assertEquals(repos[0].protocol, 'npm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('incrementDownloads', () => {
|
||||
it('should increment download count', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'downloads',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
await repo.incrementDownloads();
|
||||
assertEquals(repo.downloadCount, 1);
|
||||
|
||||
await repo.incrementDownloads();
|
||||
await repo.incrementDownloads();
|
||||
assertEquals(repo.downloadCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFullPath', () => {
|
||||
it('should return org/repo path', async () => {
|
||||
const repo = await Repository.createRepository({
|
||||
organizationId: testOrgId,
|
||||
name: 'my-package',
|
||||
protocol: 'npm',
|
||||
createdById: testUserId,
|
||||
});
|
||||
|
||||
const path = repo.getFullPath('my-org');
|
||||
assertEquals(path, 'my-org/my-package');
|
||||
});
|
||||
});
|
||||
});
|
||||
142
test/unit/models/session.test.ts
Normal file
142
test/unit/models/session.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Session model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { Session } from '../../../ts/models/session.ts';
|
||||
|
||||
describe('Session Model', () => {
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
it('should create a session with valid data', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipAddress: '192.168.1.1',
|
||||
});
|
||||
|
||||
assertExists(session.id);
|
||||
assertEquals(session.userId, testUserId);
|
||||
assertEquals(session.userAgent, 'Mozilla/5.0');
|
||||
assertEquals(session.ipAddress, '192.168.1.1');
|
||||
assertEquals(session.isValid, true);
|
||||
assertExists(session.createdAt);
|
||||
assertExists(session.lastActivityAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findValidSession', () => {
|
||||
it('should find valid session by ID', async () => {
|
||||
const created = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test Agent',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
const found = await Session.findValidSession(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
|
||||
it('should not find invalidated session', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test Agent',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
await session.invalidate('Logged out');
|
||||
|
||||
const found = await Session.findValidSession(session.id);
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserSessions', () => {
|
||||
it('should return all valid sessions for user', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
|
||||
|
||||
const sessions = await Session.getUserSessions(testUserId);
|
||||
assertEquals(sessions.length, 3);
|
||||
});
|
||||
|
||||
it('should not return invalidated sessions', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Valid', ipAddress: '1.1.1.1' });
|
||||
const invalid = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Invalid',
|
||||
ipAddress: '2.2.2.2',
|
||||
});
|
||||
await invalid.invalidate('test');
|
||||
|
||||
const sessions = await Session.getUserSessions(testUserId);
|
||||
assertEquals(sessions.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate', () => {
|
||||
it('should invalidate session with reason', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
await session.invalidate('User logged out');
|
||||
|
||||
assertEquals(session.isValid, false);
|
||||
assertExists(session.invalidatedAt);
|
||||
assertEquals(session.invalidatedReason, 'User logged out');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateAllUserSessions', () => {
|
||||
it('should invalidate all user sessions', async () => {
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 1', ipAddress: '1.1.1.1' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 2', ipAddress: '2.2.2.2' });
|
||||
await Session.createSession({ userId: testUserId, userAgent: 'Agent 3', ipAddress: '3.3.3.3' });
|
||||
|
||||
const count = await Session.invalidateAllUserSessions(testUserId, 'Security logout');
|
||||
assertEquals(count, 3);
|
||||
|
||||
const remaining = await Session.getUserSessions(testUserId);
|
||||
assertEquals(remaining.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchActivity', () => {
|
||||
it('should update lastActivityAt', async () => {
|
||||
const session = await Session.createSession({
|
||||
userId: testUserId,
|
||||
userAgent: 'Test',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
const originalActivity = session.lastActivityAt;
|
||||
|
||||
// Wait a bit to ensure time difference
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
await session.touchActivity();
|
||||
|
||||
assertEquals(session.lastActivityAt > originalActivity, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
228
test/unit/models/user.test.ts
Normal file
228
test/unit/models/user.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* User model unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertRejects } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb } from '../../helpers/index.ts';
|
||||
import { User } from '../../../ts/models/user.ts';
|
||||
|
||||
describe('User Model', () => {
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
it('should create a user with valid data', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
passwordHash,
|
||||
displayName: 'Test User',
|
||||
});
|
||||
|
||||
assertExists(user.id);
|
||||
assertEquals(user.email, 'test@example.com');
|
||||
assertEquals(user.username, 'testuser');
|
||||
assertEquals(user.displayName, 'Test User');
|
||||
assertEquals(user.status, 'pending_verification');
|
||||
assertEquals(user.emailVerified, false);
|
||||
assertEquals(user.isPlatformAdmin, false);
|
||||
});
|
||||
|
||||
it('should lowercase email and username', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'TEST@EXAMPLE.COM',
|
||||
username: 'TestUser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
assertEquals(user.email, 'test@example.com');
|
||||
assertEquals(user.username, 'testuser');
|
||||
});
|
||||
|
||||
it('should use username as displayName if not provided', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'test2@example.com',
|
||||
username: 'testuser2',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
assertEquals(user.displayName, 'testuser2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByEmail', () => {
|
||||
it('should find user by email (case-insensitive)', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
await User.createUser({
|
||||
email: 'findme@example.com',
|
||||
username: 'findme',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const found = await User.findByEmail('FINDME@example.com');
|
||||
assertExists(found);
|
||||
assertEquals(found.email, 'findme@example.com');
|
||||
});
|
||||
|
||||
it('should return null for non-existent email', async () => {
|
||||
const found = await User.findByEmail('nonexistent@example.com');
|
||||
assertEquals(found, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByUsername', () => {
|
||||
it('should find user by username (case-insensitive)', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
await User.createUser({
|
||||
email: 'user@example.com',
|
||||
username: 'findbyname',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const found = await User.findByUsername('FINDBYNAME');
|
||||
assertExists(found);
|
||||
assertEquals(found.username, 'findbyname');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find user by ID', async () => {
|
||||
const passwordHash = await User.hashPassword('testpassword');
|
||||
const created = await User.createUser({
|
||||
email: 'byid@example.com',
|
||||
username: 'byid',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const found = await User.findById(created.id);
|
||||
assertExists(found);
|
||||
assertEquals(found.id, created.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('password hashing', () => {
|
||||
it('should hash password with salt', async () => {
|
||||
const hash = await User.hashPassword('mypassword');
|
||||
assertExists(hash);
|
||||
assertEquals(hash.includes(':'), true);
|
||||
|
||||
const [salt, _hashPart] = hash.split(':');
|
||||
assertEquals(salt.length, 32); // 16 bytes = 32 hex chars
|
||||
});
|
||||
|
||||
it('should produce different hashes for same password', async () => {
|
||||
const hash1 = await User.hashPassword('samepassword');
|
||||
const hash2 = await User.hashPassword('samepassword');
|
||||
|
||||
// Different salts should produce different hashes
|
||||
assertEquals(hash1 !== hash2, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyPassword', () => {
|
||||
it('should verify correct password', async () => {
|
||||
const passwordHash = await User.hashPassword('correctpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'verify@example.com',
|
||||
username: 'verifyuser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const isValid = await user.verifyPassword('correctpassword');
|
||||
assertEquals(isValid, true);
|
||||
});
|
||||
|
||||
it('should reject incorrect password', async () => {
|
||||
const passwordHash = await User.hashPassword('correctpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'reject@example.com',
|
||||
username: 'rejectuser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const isValid = await user.verifyPassword('wrongpassword');
|
||||
assertEquals(isValid, false);
|
||||
});
|
||||
|
||||
it('should reject empty password', async () => {
|
||||
const passwordHash = await User.hashPassword('correctpassword');
|
||||
const user = await User.createUser({
|
||||
email: 'empty@example.com',
|
||||
username: 'emptyuser',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
const isValid = await user.verifyPassword('');
|
||||
assertEquals(isValid, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActive', () => {
|
||||
it('should return true for active status', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'active@example.com',
|
||||
username: 'activeuser',
|
||||
passwordHash,
|
||||
});
|
||||
user.status = 'active';
|
||||
await user.save();
|
||||
|
||||
assertEquals(user.isActive, true);
|
||||
});
|
||||
|
||||
it('should return false for suspended status', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'suspended@example.com',
|
||||
username: 'suspendeduser',
|
||||
passwordHash,
|
||||
});
|
||||
user.status = 'suspended';
|
||||
|
||||
assertEquals(user.isActive, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlatformAdmin', () => {
|
||||
it('should default to false', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'notadmin@example.com',
|
||||
username: 'notadmin',
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
assertEquals(user.isPlatformAdmin, false);
|
||||
assertEquals(user.isSystemAdmin, false);
|
||||
});
|
||||
|
||||
it('should be settable to true', async () => {
|
||||
const passwordHash = await User.hashPassword('test');
|
||||
const user = await User.createUser({
|
||||
email: 'admin@example.com',
|
||||
username: 'adminuser',
|
||||
passwordHash,
|
||||
});
|
||||
user.isPlatformAdmin = true;
|
||||
await user.save();
|
||||
|
||||
const found = await User.findById(user.id);
|
||||
assertEquals(found!.isPlatformAdmin, true);
|
||||
assertEquals(found!.isSystemAdmin, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
224
test/unit/services/auth.service.test.ts
Normal file
224
test/unit/services/auth.service.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* AuthService unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { AuthService } from '../../../ts/services/auth.service.ts';
|
||||
import { Session } from '../../../ts/models/session.ts';
|
||||
import { testConfig } from '../../test.config.ts';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: AuthService;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
authService = new AuthService({
|
||||
jwtSecret: testConfig.jwt.secret,
|
||||
accessTokenExpiresIn: 60, // 1 minute for tests
|
||||
refreshTokenExpiresIn: 300, // 5 minutes for tests
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should successfully login with valid credentials', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'login@example.com',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const result = await authService.login(user.email, password, {
|
||||
userAgent: 'TestAgent/1.0',
|
||||
ipAddress: '127.0.0.1',
|
||||
});
|
||||
|
||||
assertEquals(result.success, true);
|
||||
assertExists(result.user);
|
||||
assertEquals(result.user.id, user.id);
|
||||
assertExists(result.accessToken);
|
||||
assertExists(result.refreshToken);
|
||||
assertExists(result.sessionId);
|
||||
});
|
||||
|
||||
it('should fail with invalid email', async () => {
|
||||
const result = await authService.login('nonexistent@example.com', 'password');
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('should fail with invalid password', async () => {
|
||||
const { user } = await createTestUser({ email: 'wrongpass@example.com' });
|
||||
|
||||
const result = await authService.login(user.email, 'wrongpassword');
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('should fail for inactive user', async () => {
|
||||
const { user, password } = await createTestUser({
|
||||
email: 'inactive@example.com',
|
||||
status: 'suspended',
|
||||
});
|
||||
|
||||
const result = await authService.login(user.email, password);
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'ACCOUNT_INACTIVE');
|
||||
});
|
||||
|
||||
it('should create a session on successful login', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'session@example.com' });
|
||||
|
||||
const result = await authService.login(user.email, password);
|
||||
|
||||
assertEquals(result.success, true);
|
||||
assertExists(result.sessionId);
|
||||
|
||||
const session = await Session.findValidSession(result.sessionId!);
|
||||
assertExists(session);
|
||||
assertEquals(session.userId, user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should refresh access token with valid refresh token', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'refresh@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
assertEquals(loginResult.success, true);
|
||||
|
||||
const refreshResult = await authService.refresh(loginResult.refreshToken!);
|
||||
|
||||
assertEquals(refreshResult.success, true);
|
||||
assertExists(refreshResult.accessToken);
|
||||
assertEquals(refreshResult.sessionId, loginResult.sessionId);
|
||||
});
|
||||
|
||||
it('should fail with invalid refresh token', async () => {
|
||||
const result = await authService.refresh('invalid-token');
|
||||
|
||||
assertEquals(result.success, false);
|
||||
assertEquals(result.errorCode, 'INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should fail when session is invalidated', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'invalidsession@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
// Invalidate session
|
||||
const session = await Session.findValidSession(loginResult.sessionId!);
|
||||
await session!.invalidate('test');
|
||||
|
||||
const refreshResult = await authService.refresh(loginResult.refreshToken!);
|
||||
|
||||
assertEquals(refreshResult.success, false);
|
||||
assertEquals(refreshResult.errorCode, 'SESSION_INVALID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAccessToken', () => {
|
||||
it('should validate valid access token', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'validate@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
const validation = await authService.validateAccessToken(loginResult.accessToken!);
|
||||
|
||||
assertExists(validation);
|
||||
assertEquals(validation.user.id, user.id);
|
||||
assertEquals(validation.sessionId, loginResult.sessionId);
|
||||
});
|
||||
|
||||
it('should reject invalid access token', async () => {
|
||||
const validation = await authService.validateAccessToken('invalid-token');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject when session is invalidated', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'invalidated@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
// Invalidate session
|
||||
const session = await Session.findValidSession(loginResult.sessionId!);
|
||||
await session!.invalidate('test');
|
||||
|
||||
const validation = await authService.validateAccessToken(loginResult.accessToken!);
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should invalidate session', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'logout@example.com' });
|
||||
const loginResult = await authService.login(user.email, password);
|
||||
|
||||
const success = await authService.logout(loginResult.sessionId!);
|
||||
|
||||
assertEquals(success, true);
|
||||
|
||||
const session = await Session.findValidSession(loginResult.sessionId!);
|
||||
assertEquals(session, null);
|
||||
});
|
||||
|
||||
it('should return false for non-existent session', async () => {
|
||||
const success = await authService.logout('non-existent-session-id');
|
||||
|
||||
assertEquals(success, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logoutAll', () => {
|
||||
it('should invalidate all user sessions', async () => {
|
||||
const { user, password } = await createTestUser({ email: 'logoutall@example.com' });
|
||||
|
||||
// Create multiple sessions
|
||||
await authService.login(user.email, password);
|
||||
await authService.login(user.email, password);
|
||||
await authService.login(user.email, password);
|
||||
|
||||
const count = await authService.logoutAll(user.id);
|
||||
|
||||
assertEquals(count, 3);
|
||||
|
||||
const sessions = await Session.getUserSessions(user.id);
|
||||
assertEquals(sessions.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('static password methods', () => {
|
||||
it('should hash and verify password', async () => {
|
||||
const password = 'MySecurePassword123!';
|
||||
const hash = await AuthService.hashPassword(password);
|
||||
|
||||
const isValid = await AuthService.verifyPassword(password, hash);
|
||||
assertEquals(isValid, true);
|
||||
|
||||
const isInvalid = await AuthService.verifyPassword('WrongPassword', hash);
|
||||
assertEquals(isInvalid, false);
|
||||
});
|
||||
|
||||
it('should generate different hashes for same password', async () => {
|
||||
const password = 'SamePassword';
|
||||
const hash1 = await AuthService.hashPassword(password);
|
||||
const hash2 = await AuthService.hashPassword(password);
|
||||
|
||||
assertEquals(hash1 !== hash2, true);
|
||||
|
||||
// But both should verify
|
||||
assertEquals(await AuthService.verifyPassword(password, hash1), true);
|
||||
assertEquals(await AuthService.verifyPassword(password, hash2), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
260
test/unit/services/token.service.test.ts
Normal file
260
test/unit/services/token.service.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* TokenService unit tests
|
||||
*/
|
||||
|
||||
import { assertEquals, assertExists, assertMatch } from 'jsr:@std/assert';
|
||||
import { describe, it, beforeAll, afterAll, beforeEach } from 'jsr:@std/testing/bdd';
|
||||
import { setupTestDb, teardownTestDb, cleanupTestDb, createTestUser } from '../../helpers/index.ts';
|
||||
import { TokenService } from '../../../ts/services/token.service.ts';
|
||||
import { ApiToken } from '../../../ts/models/apitoken.ts';
|
||||
|
||||
describe('TokenService', () => {
|
||||
let tokenService: TokenService;
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await setupTestDb();
|
||||
tokenService = new TokenService();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teardownTestDb();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanupTestDb();
|
||||
const { user } = await createTestUser();
|
||||
testUserId = user.id;
|
||||
});
|
||||
|
||||
describe('createToken', () => {
|
||||
it('should create token with correct format', async () => {
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'test-token',
|
||||
protocols: ['npm', 'oci'],
|
||||
scopes: [{ protocol: '*', actions: ['read', 'write'] }],
|
||||
});
|
||||
|
||||
assertExists(result.rawToken);
|
||||
assertExists(result.token);
|
||||
|
||||
// Check token format: srg_{prefix}_{random}
|
||||
assertMatch(result.rawToken, /^srg_[a-z0-9]+_[a-z0-9]+$/);
|
||||
assertEquals(result.token.name, 'test-token');
|
||||
assertEquals(result.token.protocols.includes('npm'), true);
|
||||
assertEquals(result.token.protocols.includes('oci'), true);
|
||||
});
|
||||
|
||||
it('should store hashed token', async () => {
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'hashed-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
// The stored token should be hashed
|
||||
assertEquals(result.token.tokenHash !== result.rawToken, true);
|
||||
assertEquals(result.token.tokenHash.length, 64); // SHA-256 hex
|
||||
});
|
||||
|
||||
it('should set expiration when provided', async () => {
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'expiring-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
expiresInDays: 30,
|
||||
});
|
||||
|
||||
assertExists(result.token.expiresAt);
|
||||
|
||||
const expectedExpiry = new Date();
|
||||
expectedExpiry.setDate(expectedExpiry.getDate() + 30);
|
||||
|
||||
// Should be within a few seconds of expected
|
||||
const diff = Math.abs(result.token.expiresAt.getTime() - expectedExpiry.getTime());
|
||||
assertEquals(diff < 5000, true);
|
||||
});
|
||||
|
||||
it('should create org-owned token', async () => {
|
||||
const orgId = 'test-org-123';
|
||||
const result = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
organizationId: orgId,
|
||||
name: 'org-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', organizationId: orgId, actions: ['read', 'write'] }],
|
||||
});
|
||||
|
||||
assertEquals(result.token.organizationId, orgId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateToken', () => {
|
||||
it('should validate correct token', async () => {
|
||||
const { rawToken } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'valid-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertExists(validation);
|
||||
assertEquals(validation.userId, testUserId);
|
||||
assertEquals(validation.protocols.includes('npm'), true);
|
||||
});
|
||||
|
||||
it('should reject invalid token format', async () => {
|
||||
const validation = await tokenService.validateToken('invalid-format', '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject non-existent token', async () => {
|
||||
const validation = await tokenService.validateToken('srg_abc123_def456', '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject revoked token', async () => {
|
||||
const { rawToken, token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'revoked-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await token.revoke('Test revocation');
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should reject expired token', async () => {
|
||||
const { rawToken, token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'expired-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
expiresInDays: 1,
|
||||
});
|
||||
|
||||
// Manually set expiry to past
|
||||
token.expiresAt = new Date(Date.now() - 86400000);
|
||||
await token.save();
|
||||
|
||||
const validation = await tokenService.validateToken(rawToken, '127.0.0.1');
|
||||
|
||||
assertEquals(validation, null);
|
||||
});
|
||||
|
||||
it('should record usage on validation', async () => {
|
||||
const { rawToken, token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'usage-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await tokenService.validateToken(rawToken, '192.168.1.100');
|
||||
|
||||
// Reload token from DB
|
||||
const updated = await ApiToken.findByHash(token.tokenHash);
|
||||
assertExists(updated);
|
||||
assertExists(updated.lastUsedAt);
|
||||
assertEquals(updated.lastUsedIp, '192.168.1.100');
|
||||
assertEquals(updated.usageCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserTokens', () => {
|
||||
it('should return all user tokens', async () => {
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'token1',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'token2',
|
||||
protocols: ['oci'],
|
||||
scopes: [{ protocol: 'oci', actions: ['read'] }],
|
||||
});
|
||||
|
||||
const tokens = await tokenService.getUserTokens(testUserId);
|
||||
|
||||
assertEquals(tokens.length, 2);
|
||||
});
|
||||
|
||||
it('should not return revoked tokens', async () => {
|
||||
const { token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'revoked',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'active',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await token.revoke('test');
|
||||
|
||||
const tokens = await tokenService.getUserTokens(testUserId);
|
||||
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].name, 'active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeToken', () => {
|
||||
it('should revoke token with reason', async () => {
|
||||
const { token } = await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'to-revoke',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
await tokenService.revokeToken(token.id, 'Security concern');
|
||||
|
||||
const updated = await ApiToken.findByPrefix(token.tokenPrefix);
|
||||
assertExists(updated);
|
||||
assertEquals(updated.isRevoked, true);
|
||||
assertEquals(updated.revokedReason, 'Security concern');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOrgTokens', () => {
|
||||
it('should return organization tokens', async () => {
|
||||
const orgId = 'org-123';
|
||||
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
organizationId: orgId,
|
||||
name: 'org-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
await tokenService.createToken({
|
||||
userId: testUserId,
|
||||
name: 'personal-token',
|
||||
protocols: ['npm'],
|
||||
scopes: [{ protocol: 'npm', actions: ['read'] }],
|
||||
});
|
||||
|
||||
const tokens = await tokenService.getOrgTokens(orgId);
|
||||
|
||||
assertEquals(tokens.length, 1);
|
||||
assertEquals(tokens[0].organizationId, orgId);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user