From 740092ec971997cda380cb7ce5e1e4b98c54d97e Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Mon, 7 Mar 2022 16:20:18 +0000 Subject: [PATCH] [server] Introduce TeamSubscription.excludeFromMoreResources --- .../chargebee/team-subscription-handler.ts | 3 +- .../gitpod-db/src/team-subscription-db.ts | 1 + .../typeorm/entity/db-team-subscription.ts | 5 + .../1646410374581-TSNoMoreResources.ts | 24 +++ .../src/typeorm/team-subscription-db-impl.ts | 8 + .../src/team-subscription-protocol.ts | 2 + .../swot-js/index.d.ts} | 0 .../src/user/eligibility-service.spec.db.ts | 145 ++++++++++++++++++ .../server/ee/src/user/eligibility-service.ts | 26 +++- components/server/tsconfig.json | 6 +- 10 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 components/gitpod-db/src/typeorm/migration/1646410374581-TSNoMoreResources.ts rename components/server/ee/src/{auth/swot-js.d.ts => typings/swot-js/index.d.ts} (100%) create mode 100644 components/server/ee/src/user/eligibility-service.spec.db.ts diff --git a/components/ee/payment-endpoint/src/chargebee/team-subscription-handler.ts b/components/ee/payment-endpoint/src/chargebee/team-subscription-handler.ts index d354498136adae..86b4bb59474b08 100644 --- a/components/ee/payment-endpoint/src/chargebee/team-subscription-handler.ts +++ b/components/ee/payment-endpoint/src/chargebee/team-subscription-handler.ts @@ -72,7 +72,8 @@ export class TeamSubscriptionHandler implements EventHandler; findTeamSubscriptionById(id: string): Promise; + findTeamSubscriptionBySlotId(slotId: string): Promise; findTeamSubscriptionByPaymentRef(userId: string, paymentReference: string): Promise; findTeamSubscriptionsForUser(userId: string, date: string): Promise; findTeamSubscriptions(partial: DeepPartial): Promise; diff --git a/components/gitpod-db/src/typeorm/entity/db-team-subscription.ts b/components/gitpod-db/src/typeorm/entity/db-team-subscription.ts index fbe6b274dc713b..e2d7d3dbf307d0 100644 --- a/components/gitpod-db/src/typeorm/entity/db-team-subscription.ts +++ b/components/gitpod-db/src/typeorm/entity/db-team-subscription.ts @@ -51,4 +51,9 @@ export class DBTeamSubscription implements TeamSubscription { default: false }) deleted?: boolean; + + @Column({ + default: false + }) + excludeFromMoreResources: boolean; } diff --git a/components/gitpod-db/src/typeorm/migration/1646410374581-TSNoMoreResources.ts b/components/gitpod-db/src/typeorm/migration/1646410374581-TSNoMoreResources.ts new file mode 100644 index 00000000000000..e891b77e657e6f --- /dev/null +++ b/components/gitpod-db/src/typeorm/migration/1646410374581-TSNoMoreResources.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import {MigrationInterface, QueryRunner} from "typeorm"; +import { columnExists } from "./helper/helper"; + +const TABLE_NAME = "d_b_team_subscription"; +const COLUMN_NAME = "excludeFromMoreResources"; + +export class TSExcludeFromMoreResources1646410374581 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + if (!(await columnExists(queryRunner, TABLE_NAME, COLUMN_NAME))) { + await queryRunner.query(`ALTER TABLE ${TABLE_NAME} ADD COLUMN ${COLUMN_NAME} tinyint(4) NOT NULL DEFAULT '0', ALGORITHM=INPLACE, LOCK=NONE `); + } + } + + public async down(queryRunner: QueryRunner): Promise { + } + +} diff --git a/components/gitpod-db/src/typeorm/team-subscription-db-impl.ts b/components/gitpod-db/src/typeorm/team-subscription-db-impl.ts index 03c8c437979bb9..203a527696a3a4 100644 --- a/components/gitpod-db/src/typeorm/team-subscription-db-impl.ts +++ b/components/gitpod-db/src/typeorm/team-subscription-db-impl.ts @@ -51,6 +51,14 @@ export class TeamSubscriptionDBImpl implements TeamSubscriptionDB { return repo.findOne(id); } + async findTeamSubscriptionBySlotId(slotId: string): Promise { + const repo = await this.getRepo(); + const query = repo.createQueryBuilder("ts") + .leftJoinAndMapOne("ts.id", DBTeamSubscriptionSlot, "slot", "slot.teamSubscriptionId = ts.id") + .where("slot.id = :slotId", { slotId }); + return query.getOne(); + } + async findTeamSubscriptionByPaymentRef(userId: string, paymentReference: string): Promise { const repo = await this.getRepo(); return repo.findOne({ userId, paymentReference }); diff --git a/components/gitpod-protocol/src/team-subscription-protocol.ts b/components/gitpod-protocol/src/team-subscription-protocol.ts index 42ce2185e56663..09a290ffec6d09 100644 --- a/components/gitpod-protocol/src/team-subscription-protocol.ts +++ b/components/gitpod-protocol/src/team-subscription-protocol.ts @@ -18,6 +18,8 @@ export interface TeamSubscription { paymentReference: string; cancellationDate?: string; deleted?: boolean; + /** If this flag is set slots are not eligibile for clusters with "more-resources" - even if their plan might be */ + excludeFromMoreResources: boolean; } export namespace TeamSubscription { diff --git a/components/server/ee/src/auth/swot-js.d.ts b/components/server/ee/src/typings/swot-js/index.d.ts similarity index 100% rename from components/server/ee/src/auth/swot-js.d.ts rename to components/server/ee/src/typings/swot-js/index.d.ts diff --git a/components/server/ee/src/user/eligibility-service.spec.db.ts b/components/server/ee/src/user/eligibility-service.spec.db.ts new file mode 100644 index 00000000000000..66f68c161b1a94 --- /dev/null +++ b/components/server/ee/src/user/eligibility-service.spec.db.ts @@ -0,0 +1,145 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the Gitpod Enterprise Source Code License, + * See License.enterprise.txt in the project root folder. + */ + +import { testContainer } from '@gitpod/gitpod-db/lib/test-container'; +import { DBUser, DBIdentity, UserDB, AccountingDB, TeamSubscriptionDB } from '@gitpod/gitpod-db/lib'; +import { TypeORM } from '@gitpod/gitpod-db/lib/typeorm/typeorm'; +import { Subscription } from '@gitpod/gitpod-protocol/lib/accounting-protocol'; +import { Plans } from '@gitpod/gitpod-protocol/lib/plans'; +import * as chai from 'chai'; +import { suite, test, timeout } from 'mocha-typescript'; +import { Config } from '../../../src/config'; +import { EligibilityService } from './eligibility-service'; +import { DBSubscription } from '@gitpod/gitpod-db/lib/typeorm/entity/db-subscription'; +import { DBTeamSubscription } from '@gitpod/gitpod-db/lib/typeorm/entity/db-team-subscription'; +import { DBTeamSubscriptionSlot } from '@gitpod/gitpod-db/lib/typeorm/entity/db-team-subscription-slot'; +import { Token, User } from '@gitpod/gitpod-protocol'; +import { AccountService, AccountServiceImpl, SubscriptionService } from '@gitpod/gitpod-payment-endpoint/lib/accounting'; +import { EMailDomainService, EMailDomainServiceImpl } from '../auth/email-domain-service'; +import { TokenProvider } from "../../../src/user/token-provider"; +import { AccountStatementProvider } from './account-statement-provider'; + +const expect = chai.expect; + +const localTestContainer = testContainer.createChild(); +localTestContainer.bind(EligibilityService).toSelf().inSingletonScope(); +localTestContainer.bind(Config).toDynamicValue(ctx => ({ + enablePayment: true, +} as Config)).inSingletonScope(); +localTestContainer.bind(SubscriptionService).toSelf().inSingletonScope(); + +localTestContainer.bind(EMailDomainService).to(EMailDomainServiceImpl).inSingletonScope(); + +localTestContainer.bind(TokenProvider).toDynamicValue(ctx => { + getFreshPortAuthenticationToken: (user: User, host: string) => { return {} as Promise }, + getTokenForHost: (user: User, workspaceId: string) => { return {} as Promise }, +}); + +localTestContainer.bind(AccountStatementProvider).toSelf().inRequestScope(); +localTestContainer.bind(AccountServiceImpl).toSelf().inSingletonScope(); +localTestContainer.bind(AccountService).toService(AccountServiceImpl); + +const start = new Date(Date.UTC(2000, 0, 1)).toISOString(); +const userId = 'Niemand'; +const tsId = 'someTeamSubscription'; +const subscriptionId = 'theSubscriptionForTsSlot'; +const slotId = 'someSlotId'; + +@timeout(10000) +@suite class AccountServiceSpec { + typeORM = localTestContainer.get(TypeORM); + cut = localTestContainer.get(EligibilityService); + + userDb = localTestContainer.get(UserDB); + accountingDb = localTestContainer.get(AccountingDB); + tsDb = localTestContainer.get(TeamSubscriptionDB); + + subscription: Subscription; + user: User; + + @timeout(10000) + async before() { + await this.purgeDB(); + + this.user = await this.userDb.storeUser({ + id: userId, + creationDate: start, + fullName: 'Herr Niemand', + identities: [{ + authProviderId: 'github.com', + authId: 'Niemand', + authName: 'Niemand', + tokens: [] + }], + }); + + this.subscription = await this.accountingDb.newSubscription({ + userId, + startDate: start, + amount: 100, + planId: 'test' + }); + } + + protected async purgeDB() { + const manager = (await this.typeORM.getConnection()).manager; + await manager.query('SET FOREIGN_KEY_CHECKS = 0;'); + await manager.clear(DBIdentity); + await manager.clear(DBUser); + await manager.query('SET FOREIGN_KEY_CHECKS = 1;'); + + await manager.clear(DBSubscription); + await manager.clear(DBTeamSubscription); + await manager.clear(DBTeamSubscriptionSlot); + } + + protected async createTsSubscription(excludeFromMoreResources: boolean = false) { + const plan = Plans.TEAM_PROFESSIONAL_EUR; + const ts = await this.tsDb.storeTeamSubscriptionEntry({ + id: tsId, + userId, + paymentReference: "abcdef", + planId: plan.chargebeeId, + quantity: 1, + startDate: start, + excludeFromMoreResources, + }); + + const slot = await this.tsDb.storeSlot({ + id: slotId, + teamSubscriptionId: tsId, + assigneeId: userId, + subscriptionId, + }); + + const sub = await this.accountingDb.storeSubscription(Subscription.create({ + userId, + planId: plan.chargebeeId, + startDate: start, + amount: Plans.getHoursPerMonth(plan), + teamSubscriptionSlotId: slot.id, + })); + return { plan, sub, ts, slot }; + } + + @timeout(5000) + @test async testUserGetsMoreResources() { + await this.createTsSubscription(); + + const actual = await this.cut.userGetsMoreResources(this.user); + expect(actual, "user with Team Unleashed gets 'more resources'").to.equal(true); + } + + @timeout(5000) + @test async testUserGetsMoreResources_excludeFromMoreResources() { + await this.createTsSubscription(true); + + const actual = await this.cut.userGetsMoreResources(this.user); + expect(actual, "user with Team Unleashed but excludeFromMoreResources set does not get 'more resources'").to.equal(false); + } +} + +module.exports = new AccountServiceSpec() diff --git a/components/server/ee/src/user/eligibility-service.ts b/components/server/ee/src/user/eligibility-service.ts index dd3d22d6509048..440e527976e7ae 100644 --- a/components/server/ee/src/user/eligibility-service.ts +++ b/components/server/ee/src/user/eligibility-service.ts @@ -5,8 +5,7 @@ */ import { inject, injectable } from "inversify"; -import { UserDB } from "@gitpod/gitpod-db/lib"; -import { HostContextProvider } from "../../../src/auth/host-context-provider"; +import { TeamSubscriptionDB, UserDB } from "@gitpod/gitpod-db/lib"; import { TokenProvider } from "../../../src/user/token-provider"; import { User, WorkspaceTimeoutDuration, WorkspaceInstance } from "@gitpod/gitpod-protocol"; import { RemainingHours } from "@gitpod/gitpod-protocol/lib/accounting-protocol"; @@ -47,10 +46,10 @@ export class EligibilityService { @inject(Config) protected readonly config: Config; @inject(UserDB) protected readonly userDb: UserDB; @inject(SubscriptionService) protected readonly subscriptionService: SubscriptionService; - @inject(HostContextProvider) protected readonly hostContextProvider: HostContextProvider; @inject(EMailDomainService) protected readonly domainService: EMailDomainService; @inject(TokenProvider) protected readonly tokenProvider: TokenProvider; @inject(AccountStatementProvider) protected readonly accountStatementProvider: AccountStatementProvider; + @inject(TeamSubscriptionDB) protected readonly teamSubscriptionDb: TeamSubscriptionDB; /** * Whether the given user is recognized as a student within Gitpod @@ -270,7 +269,26 @@ export class EligibilityService { Plans.TEAM_PROFESSIONAL_USD, ].map(p => p.chargebeeId); - return subscriptions.filter(s => eligblePlans.includes(s.planId!)).length > 0; + const relevantSubscriptions = subscriptions.filter(s => eligblePlans.includes(s.planId!)); + if (relevantSubscriptions.length === 0) { + // user has no subscription that grants "more resources" + return false; + } + + // some TeamSubscriptions are marked with 'excludeFromMoreResources' to convey that those are _not_ receiving more resources + const excludeFromMoreResources = await Promise.all(relevantSubscriptions.map(async (s): Promise => { + if (!s.teamSubscriptionSlotId) { + return false; + } + const ts = await this.teamSubscriptionDb.findTeamSubscriptionBySlotId(s.teamSubscriptionSlotId); + return !!ts?.excludeFromMoreResources; + })); + if (excludeFromMoreResources.every(b => b)) { + // if all TS the user is part of are marked this way, we deny that privilege + return false; + } + + return true; } protected async getUser(user: User | string): Promise { diff --git a/components/server/tsconfig.json b/components/server/tsconfig.json index 3b2130bd8948fd..550bf095440ac4 100644 --- a/components/server/tsconfig.json +++ b/components/server/tsconfig.json @@ -20,7 +20,11 @@ "declaration": true, "declarationMap": true, "skipLibCheck": true, - "useUnknownInCatchVariables": false + "useUnknownInCatchVariables": false, + "typeRoots": [ + "../../node_modules/@types", + "./ee/src/typings" + ] }, "include": [ "src",