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:
@@ -15,6 +15,15 @@ export class OrganizationApi {
|
||||
this.permissionService = permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve organization by ID or name
|
||||
*/
|
||||
private async resolveOrganization(idOrName: string): Promise<Organization | null> {
|
||||
return idOrName.startsWith('Organization:')
|
||||
? await Organization.findById(idOrName)
|
||||
: await Organization.findByName(idOrName);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations
|
||||
*/
|
||||
@@ -56,19 +65,20 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id
|
||||
* Supports lookup by ID (e.g., Organization:abc123) or by name (e.g., push.rocks)
|
||||
*/
|
||||
public async get(ctx: IApiContext): Promise<IApiResponse> {
|
||||
const { id } = ctx.params;
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check access - public orgs are visible to all authenticated users
|
||||
if (!org.isPublic && ctx.actor?.userId) {
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
const isMember = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
@@ -112,11 +122,11 @@ export class OrganizationApi {
|
||||
return { status: 400, body: { error: 'Organization name is required' } };
|
||||
}
|
||||
|
||||
// Validate name format
|
||||
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
||||
// Validate name format (allows dots for domain-like names)
|
||||
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(name)) {
|
||||
return {
|
||||
status: 400,
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens' },
|
||||
body: { error: 'Name must be lowercase alphanumeric with optional hyphens and dots' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,6 +186,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async update(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -184,18 +195,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission using org.id
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { displayName, description, avatarUrl, website, isPublic, settings } = body;
|
||||
|
||||
@@ -232,6 +243,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async delete(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -240,18 +252,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Owner access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await Organization.findById(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Only owners and system admins can delete
|
||||
const membership = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
|
||||
if (membership?.role !== 'owner' && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Owner access required' } };
|
||||
}
|
||||
|
||||
// TODO: Check for packages, repositories before deletion
|
||||
// For now, just delete the organization and memberships
|
||||
await org.delete();
|
||||
@@ -268,6 +280,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* GET /api/v1/organizations/:id/members
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async listMembers(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -276,14 +289,19 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check membership
|
||||
const isMember = await OrganizationMember.findMembership(id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await OrganizationMember.getOrgMembers(id);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check membership
|
||||
const isMember = await OrganizationMember.findMembership(org.id, ctx.actor.userId);
|
||||
if (!isMember && !ctx.actor.user?.isSystemAdmin) {
|
||||
return { status: 403, body: { error: 'Access denied' } };
|
||||
}
|
||||
|
||||
const members = await OrganizationMember.getOrgMembers(org.id);
|
||||
|
||||
// Fetch user details
|
||||
const membersWithUsers = await Promise.all(
|
||||
@@ -316,6 +334,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* POST /api/v1/organizations/:id/members
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async addMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -324,13 +343,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { userId, role } = body as { userId: string; role: TOrganizationRole };
|
||||
|
||||
@@ -349,7 +373,7 @@ export class OrganizationApi {
|
||||
}
|
||||
|
||||
// Check if already a member
|
||||
const existing = await OrganizationMember.findMembership(id, userId);
|
||||
const existing = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (existing) {
|
||||
return { status: 409, body: { error: 'User is already a member' } };
|
||||
}
|
||||
@@ -357,7 +381,7 @@ export class OrganizationApi {
|
||||
// Add member
|
||||
const membership = new OrganizationMember();
|
||||
membership.id = await OrganizationMember.getNewId();
|
||||
membership.organizationId = id;
|
||||
membership.organizationId = org.id;
|
||||
membership.userId = userId;
|
||||
membership.role = role;
|
||||
membership.addedById = ctx.actor.userId;
|
||||
@@ -366,11 +390,8 @@ export class OrganizationApi {
|
||||
await membership.save();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
}
|
||||
org.memberCount += 1;
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
@@ -388,6 +409,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* PUT /api/v1/organizations/:id/members/:userId
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async updateMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -396,13 +418,18 @@ export class OrganizationApi {
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
try {
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Check admin permission
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
|
||||
const body = await ctx.request.json();
|
||||
const { role } = body as { role: TOrganizationRole };
|
||||
|
||||
@@ -410,14 +437,14 @@ export class OrganizationApi {
|
||||
return { status: 400, body: { error: 'Valid role is required' } };
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
const membership = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot change last owner
|
||||
if (membership.role === 'owner' && role !== 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const owners = await OrganizationMember.getOrgMembers(org.id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
@@ -442,6 +469,7 @@ export class OrganizationApi {
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/organizations/:id/members/:userId
|
||||
* Supports lookup by ID or name
|
||||
*/
|
||||
public async removeMember(ctx: IApiContext): Promise<IApiResponse> {
|
||||
if (!ctx.actor?.userId) {
|
||||
@@ -450,23 +478,28 @@ export class OrganizationApi {
|
||||
|
||||
const { id, userId } = ctx.params;
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (userId !== ctx.actor.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const membership = await OrganizationMember.findMembership(id, userId);
|
||||
const org = await this.resolveOrganization(id);
|
||||
if (!org) {
|
||||
return { status: 404, body: { error: 'Organization not found' } };
|
||||
}
|
||||
|
||||
// Users can remove themselves, admins can remove others
|
||||
if (userId !== ctx.actor.userId) {
|
||||
const canManage = await this.permissionService.canManageOrganization(ctx.actor.userId, org.id);
|
||||
if (!canManage) {
|
||||
return { status: 403, body: { error: 'Admin access required' } };
|
||||
}
|
||||
}
|
||||
|
||||
const membership = await OrganizationMember.findMembership(org.id, userId);
|
||||
if (!membership) {
|
||||
return { status: 404, body: { error: 'Member not found' } };
|
||||
}
|
||||
|
||||
// Cannot remove last owner
|
||||
if (membership.role === 'owner') {
|
||||
const owners = await OrganizationMember.getOrgMembers(id);
|
||||
const owners = await OrganizationMember.getOrgMembers(org.id);
|
||||
const ownerCount = owners.filter((m) => m.role === 'owner').length;
|
||||
if (ownerCount <= 1) {
|
||||
return { status: 400, body: { error: 'Cannot remove the last owner' } };
|
||||
@@ -476,11 +509,8 @@ export class OrganizationApi {
|
||||
await membership.delete();
|
||||
|
||||
// Update member count
|
||||
const org = await Organization.findById(id);
|
||||
if (org) {
|
||||
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||
await org.save();
|
||||
}
|
||||
org.memberCount = Math.max(0, org.memberCount - 1);
|
||||
await org.save();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
|
||||
@@ -48,6 +48,9 @@ export interface IOrganization {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
avatarUrl?: string;
|
||||
website?: string;
|
||||
isPublic: boolean;
|
||||
memberCount: number;
|
||||
plan: TOrganizationPlan;
|
||||
settings: IOrganizationSettings;
|
||||
billingEmail?: string;
|
||||
|
||||
@@ -37,6 +37,15 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
@plugins.smartdata.svDb()
|
||||
public avatarUrl?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public website?: string;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public isPublic: boolean = false;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
public memberCount: number = 0;
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
@plugins.smartdata.index()
|
||||
public plan: TOrganizationPlan = 'free';
|
||||
@@ -79,11 +88,11 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
description?: string;
|
||||
createdById: string;
|
||||
}): Promise<Organization> {
|
||||
// Validate name (URL-safe)
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||||
// Validate name (URL-safe, allows dots for domain-like names)
|
||||
const nameRegex = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
||||
if (!nameRegex.test(data.name)) {
|
||||
throw new Error(
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens'
|
||||
'Organization name must be lowercase alphanumeric with optional hyphens and dots'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +109,13 @@ export class Organization extends plugins.smartdata.SmartDataDbDoc<Organization,
|
||||
return org;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find organization by ID
|
||||
*/
|
||||
public static async findById(id: string): Promise<Organization | null> {
|
||||
return await Organization.getInstance({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find organization by name (slug)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user