From 209d2879a351781e0e37b2a3f992245d74410fbb Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:35:50 +0700 Subject: [PATCH 1/2] fix(billing): only the org owner's DOS entitlement may write the org subscription Incident 2026-09-22: a free-plan member login (test1@dos.me, ADMIN in the org) triggered the DOS shared-billing sync on the users endpoint and clearDosSyncedSubscription - deleteMany({ organizationId }) - wiped the org's ULTIMATE stripe subscription. Any free-plan member of any paid org could do this on every page load. - syncOrg now resolves the caller's membership role and only proceeds to clear/sync when the role is SUPERADMIN (the owner role this codebase assigns to org creators). Members get a read-only mapped view of their own DOS plan instead; the org subscription is untouched. - tests/bootstrap-dos-sync-guard.spec.ts: 5 pure unit cases (owner free clears, owner plus syncs, member free/plus read-only, non-DOS user no write). Repository modules are stubbed with explicit jest.mock factories - their real prisma import graph cannot load in the CJS jest context. Prod data was restored separately (subscription recreated, isLifetime flipped back on the 3 orgs). --- .../dos-billing/dos-shared-billing.service.ts | 21 ++- tests/bootstrap-dos-sync-guard.spec.ts | 124 ++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 tests/bootstrap-dos-sync-guard.spec.ts diff --git a/libraries/nestjs-libraries/src/dos-billing/dos-shared-billing.service.ts b/libraries/nestjs-libraries/src/dos-billing/dos-shared-billing.service.ts index bf68642233..e2c6815663 100644 --- a/libraries/nestjs-libraries/src/dos-billing/dos-shared-billing.service.ts +++ b/libraries/nestjs-libraries/src/dos-billing/dos-shared-billing.service.ts @@ -1,5 +1,6 @@ import { HttpException, Injectable, Logger } from '@nestjs/common'; -import { Provider, User } from '@prisma/client'; +import { Provider, Role, User } from '@prisma/client'; +import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository'; import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; import { isDosSharedBillingEnabled } from './crove-billing-gate'; import { DosMeBillingClient } from './dos-me-billing.client'; @@ -16,7 +17,8 @@ export class DosSharedBillingService { constructor( private readonly client: DosMeBillingClient, - private readonly subscriptions: SubscriptionService + private readonly subscriptions: SubscriptionService, + private readonly organizations: OrganizationRepository ) {} enabled() { @@ -42,6 +44,21 @@ export class DosSharedBillingService { return mapDosPlanToCrove('free'); } + // Only the organization OWNER (role SUPERADMIN - the owner role this + // codebase assigns to org creators) may drive the org's subscription + // from their DOS entitlement. A member login - even ADMIN - must never + // clear or downgrade a paid org subscription: clearDosSyncedSubscription + // is deleteMany({ organizationId }) and a free-plan member login wiped + // the JOY org's ULTIMATE subscription on 2026-09-22. Members get a + // read-only view of their own DOS plan instead. + const membership = await this.organizations + .getOrgsByUserId(user.id) + .then((orgs) => orgs.find((o) => o.id === organizationId)); + if (membership?.users?.[0]?.role !== Role.SUPERADMIN) { + const entitlement = await this.client.getEntitlement(dosUserId); + return mapDosPlanToCrove(entitlement.plan); + } + const entitlement = await this.client.getEntitlement(dosUserId); const mapped = mapDosPlanToCrove(entitlement.plan); const cancelAt = entitlement.current_period_end diff --git a/tests/bootstrap-dos-sync-guard.spec.ts b/tests/bootstrap-dos-sync-guard.spec.ts new file mode 100644 index 0000000000..d270885655 --- /dev/null +++ b/tests/bootstrap-dos-sync-guard.spec.ts @@ -0,0 +1,124 @@ +import { mock } from 'jest-mock-extended'; +import { Provider, Role } from '@prisma/client'; +import { DosSharedBillingService } from '@gitroom/nestjs-libraries/dos-billing/dos-shared-billing.service'; +import { DosMeBillingClient } from '@gitroom/nestjs-libraries/dos-billing/dos-me-billing.client'; +import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository'; + +// Pure unit suite: the guard under test is the 2026-09-22 incident fix where +// a free-plan MEMBER login wiped the org's paid subscription through +// clearDosSyncedSubscription (deleteMany by organizationId). Only the org +// owner's login may write; members get a read-only view of their own plan. + +// Stub the two repository modules so their real implementations (and the +// wide prisma import graph behind them) never load in this CJS jest context. +jest.mock( + '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service', + () => ({ SubscriptionService: class SubscriptionService {} }) +); +jest.mock( + '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository', + () => ({ OrganizationRepository: class OrganizationRepository {} }) +); + +const ORG_ID = 'org-1'; + +const freeEntitlement = { + user_id: '550e8400-e29b-41d4-a716-446655440000', + plan: 'free', + active_subscription_source: 'none', + active_subscription_id: null, + current_period_start: null, + current_period_end: null, +}; + +const plusEntitlement = { + user_id: '550e8400-e29b-41d4-a716-446655440000', + plan: 'plus', + active_subscription_source: 'stripe', + active_subscription_id: 'sub_test_1', + current_period_start: '2026-09-01T00:00:00Z', + current_period_end: '2026-10-01T00:00:00Z', +}; + +function userFixture() { + return { + id: 'user-1', + providerName: Provider.GENERIC, + providerId: '550e8400-e29b-41d4-a716-446655440000', + } as any; +} + +function orgsFixture(role: Role) { + return [ + { + id: ORG_ID, + users: [{ disabled: false, role }], + subscription: null, + }, + ] as any; +} + +function buildService( + entitlement: typeof freeEntitlement, + role: Role +): { + service: DosSharedBillingService; + subscriptions: ReturnType>; +} { + const client = mock(); + client.getEntitlement.mockResolvedValue(entitlement as any); + const subscriptions = mock(); + const organizations = mock(); + (organizations.getOrgsByUserId as any).mockResolvedValue(orgsFixture(role)); + return { + service: new DosSharedBillingService(client, subscriptions, organizations), + subscriptions, + }; +} + +describe('DosSharedBillingService.syncOrg owner guard', () => { + it('owner login with a FREE DOS plan clears the synced subscription', async () => { + const { service, subscriptions } = buildService(freeEntitlement, Role.SUPERADMIN); + await service.syncOrg(userFixture(), ORG_ID); + expect(subscriptions.clearDosSyncedSubscription).toHaveBeenCalledWith(ORG_ID); + }); + + it('owner login with a PLUS DOS plan syncs the org subscription', async () => { + const { service, subscriptions } = buildService(plusEntitlement, Role.SUPERADMIN); + const mapped = await service.syncOrg(userFixture(), ORG_ID); + expect(subscriptions.syncFromDosPlan).toHaveBeenCalledWith( + ORG_ID, + 'STANDARD', + 5, + 'sub_test_1', + new Date('2026-10-01T00:00:00Z') + ); + expect(mapped.tier).toBe('STANDARD'); + }); + + it('member login with a FREE DOS plan never writes the org subscription', async () => { + const { service, subscriptions } = buildService(freeEntitlement, Role.ADMIN); + const mapped = await service.syncOrg(userFixture(), ORG_ID); + expect(subscriptions.clearDosSyncedSubscription).not.toHaveBeenCalled(); + expect(subscriptions.syncFromDosPlan).not.toHaveBeenCalled(); + expect(mapped.tier).toBe('FREE'); + }); + + it('member login with a PLUS DOS plan gets a read-only view, no org write', async () => { + const { service, subscriptions } = buildService(plusEntitlement, Role.ADMIN); + const mapped = await service.syncOrg(userFixture(), ORG_ID); + expect(subscriptions.clearDosSyncedSubscription).not.toHaveBeenCalled(); + expect(subscriptions.syncFromDosPlan).not.toHaveBeenCalled(); + expect(mapped.tier).toBe('STANDARD'); + }); + + it('user without a DOS UUID providerId is treated as free, no write', async () => { + const { service, subscriptions } = buildService(plusEntitlement, Role.SUPERADMIN); + const localUser = { id: 'user-2', providerName: Provider.LOCAL, providerId: '' } as any; + const mapped = await service.syncOrg(localUser, ORG_ID); + expect(subscriptions.clearDosSyncedSubscription).not.toHaveBeenCalled(); + expect(subscriptions.syncFromDosPlan).not.toHaveBeenCalled(); + expect(mapped.tier).toBe('FREE'); + }); +}); From 54837a8ee2fcd2b6d194ff7fb431c9238134ba1d Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:45:44 +0700 Subject: [PATCH 2/2] test(billing): correct the stub rationale comment in the guard suite The reviewer verified the real repository modules do in fact load in this CJS jest context (the file-type ESM claim was wrong); the stubs exist for isolation so interaction assertions stay on the injected instances. State that accurately. --- tests/bootstrap-dos-sync-guard.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/bootstrap-dos-sync-guard.spec.ts b/tests/bootstrap-dos-sync-guard.spec.ts index d270885655..c9dd82bf25 100644 --- a/tests/bootstrap-dos-sync-guard.spec.ts +++ b/tests/bootstrap-dos-sync-guard.spec.ts @@ -10,8 +10,10 @@ import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prism // clearDosSyncedSubscription (deleteMany by organizationId). Only the org // owner's login may write; members get a read-only view of their own plan. -// Stub the two repository modules so their real implementations (and the -// wide prisma import graph behind them) never load in this CJS jest context. +// Stub the two repository modules with explicit jest.mock factories so the +// suite stays isolated: interaction assertions run against the instances +// injected through the constructor, and the real prisma-backed +// implementations never load in this CJS jest context. jest.mock( '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service', () => ({ SubscriptionService: class SubscriptionService {} })