mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-07 00:02:07 +00:00
Merge pull request #846 from warmbly/devin/1791216084-ready-test-workspaces
feat: provision ready-to-use Test workspaces for reviewer accounts
This commit is contained in:
@@ -76,6 +76,7 @@ const columns: Column<AdminOrgListItem>[] = [
|
||||
>
|
||||
{o.name}
|
||||
</Link>
|
||||
{o.category === "test" && <StatusBadge tone="info" className="ml-2">Test</StatusBadge>}
|
||||
{o.slug && <div className="mt-0.5 font-mono text-[11px] text-subtle-foreground">{o.slug}</div>}
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -141,9 +141,10 @@ export default function TestersPage() {
|
||||
description={
|
||||
<>
|
||||
Accounts for people outside the team. Each skips the emailed login code, because the
|
||||
holder cannot read this instance's mail. Everything else still applies: the password,
|
||||
holder cannot read this instance's mail, and arrives with onboarding complete. Authentication still requires the password,
|
||||
the captcha and the sign-in risk assessment. A tester either gets a workspace of its own
|
||||
or joins one that already exists. Its password stops working on the date you choose, and
|
||||
or joins one that already exists. A new Test workspace includes paid-feature access and 100 test credits until the password expires.
|
||||
An existing workspace keeps its plan and the role you select. Its password stops working on the date you choose, and
|
||||
revoking it ends the password and signs out every session at once.
|
||||
</>
|
||||
}
|
||||
@@ -160,7 +161,7 @@ export default function TestersPage() {
|
||||
<div className="mt-0.5 text-[12.5px] text-muted-foreground">
|
||||
{created.joined_existing
|
||||
? "This account is a member of an existing workspace and will land in it on sign-in."
|
||||
: "This account owns a new, empty workspace."}
|
||||
: "This account owns a new Test workspace with paid-feature access and 100 test credits until the password expires."}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setCreated(null)}>
|
||||
|
||||
@@ -731,6 +731,7 @@ export interface AdminUserPreview {
|
||||
// /admin/organizations* — workspace admin (read-only slice).
|
||||
|
||||
export interface AdminOrgListItem {
|
||||
category: "standard" | "test";
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
|
||||
@@ -117,16 +117,20 @@ The admin panel has the same thing under **Testers**: create one, see the passwo
|
||||
|
||||
#### Which workspace a tester lands in
|
||||
|
||||
A tester either gets a workspace of its own or joins one that already exists, and the choice is on the create form.
|
||||
A tester either gets a workspace of its own or joins one that already exists, and the choice is on the create form. Accounts created through **Testers** arrive with onboarding complete, regardless of that choice. Exempting an existing account with the CLI does not complete its onboarding or change its workspace plan.
|
||||
|
||||
Its own is the default and the safer one. The reviewer connects their own mailbox and exercises the app without reaching anything of yours, which is usually all an OAuth verification needs to see.
|
||||
Its own is the default and the safer one. The reviewer connects their own mailbox and exercises the app without reaching anything of yours, which is usually all an OAuth verification needs to see. The workspace is categorized as **Test** in the admin organization list and gets a private managed Test plan: paid-product features, including mailbox OAuth, campaigns, warmup, Unibox and AI, with 100 test credits. The dashboard header reads **Test**, not a customer plan name, and the credit gauge labels its allowance **Test credits**. Its allowance includes 10,000 contacts, 100 campaigns (20 active), 10 team members and a workspace send budget of 1,000 emails per day. Mailbox send spacing, reputation checks, captcha, authentication and abuse controls still apply. The category is informational, not an authorization bypass.
|
||||
|
||||
The managed plan expires when the handed-out password expires, and revoking the tester ends that grant immediately. Credits do not bypass feature gating after the grant ends. Existing dedicated reviewer workspaces recorded by tester creation are categorized on upgrade; still-active reviewers receive the grant and credits unless the workspace already has a Stripe subscription or an operator-managed plan. Legacy admin-created reviewers without a password expiry get the default 30-day lifetime measured from their original creation, not from the upgrade. Expired or revoked reviewers receive no renewed access. Existing memberships and data are not deleted.
|
||||
|
||||
Connecting Gmail still needs configured `BOX_GOOGLE_CLIENT_ID` and `BOX_GOOGLE_CLIENT_SECRET`, and the backend and frontend mailbox OAuth flags must permit new connections. A Test plan does not supply provider credentials or change those flags. Dashboard Google sign-in uses the separate `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` configuration. See [configuration](/development/configuration/).
|
||||
|
||||
Joining an existing workspace is for a review judged on the app doing real work, where an empty workspace shows none of it. The tester becomes an ordinary member of that workspace and gets none of its own, so signing in lands straight in it: the dashboard skips its workspace picker only when someone belongs to exactly one. Two things follow from that, and both are the point rather than a side effect:
|
||||
|
||||
- **You pick the role, and there is no default.** This is the one path that grants workspace access without anybody in that workspace asking for it, so the form will not submit until a role is named. The role decides everything the holder can reach, and it is recorded on the audit row as well as the members table, which the role can be changed out of later.
|
||||
- **The tester can see real data.** A role carrying `access_unibox` or `manage_emails` means real mail and real mailboxes, belonging to real people. Give the narrowest role the review actually needs, and revoke it when the review ends.
|
||||
|
||||
The tester occupies a seat like any other member, so a workspace at its team member limit refuses the join rather than quietly exceeding it.
|
||||
The tester occupies a seat like any other member, so a workspace at its team member limit refuses the join rather than quietly exceeding it. Joining does not change the workspace category, grant a Test plan or add credits; its existing plan and the selected role still govern access.
|
||||
|
||||
`warmblyctl status` lists every exempt account on each run with its reason and the date, because the way this goes wrong is not granting one, it is forgetting it. Remove one with:
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ Everything here lives on the **AI & credits** tab of **Settings > Billing** and
|
||||
|
||||
## The two pools
|
||||
|
||||
A dedicated reviewer workspace starts with 100 test credits. Its header gauge and popover label them **Test credits** rather than plan credits. These use the normal credit ledger and spend rules, and do not keep paid features unlocked after the reviewer grant expires. Joining an existing workspace as a tester does not add credits. See [reviewer accounts](/development/accounts-and-access/#which-workspace-a-tester-lands-in).
|
||||
|
||||
| Pool | Refresh | Rollover |
|
||||
|------|---------|----------|
|
||||
| **Monthly allowance** | Reset to the plan amount each billing cycle | No, unused credits are replaced by the fresh grant |
|
||||
|
||||
@@ -7,6 +7,8 @@ Every hosted workspace starts free. The free workspace warms up to 10 mailboxes,
|
||||
|
||||
## Plans
|
||||
|
||||
Dedicated reviewer workspaces show **Test** in the header instead of a customer plan. They have operator-granted, time-limited access, not a purchased subscription. Joining an existing workspace as a tester does not change its plan badge. See [reviewer accounts](/development/accounts-and-access/#which-workspace-a-tester-lands-in).
|
||||
|
||||
| Plan | Monthly | Annual (per month) | Sends per day | Includes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Starter | $29 | $23 | 150 | Unlimited warmup, unlimited mailboxes, unified inbox, team invitations, bulk contact operations |
|
||||
|
||||
@@ -139,6 +139,8 @@ If you have shell access to the source, `warmblyctl org export` writes the same
|
||||
|
||||
## What does not travel
|
||||
|
||||
The Test workspace classification is instance-local. Importing an archive does not classify the destination as Test or grant a managed Test plan or testing credits.
|
||||
|
||||
A few things belong to the instance rather than the workspace and are left out on purpose: the instance's link to Warmbly Cloud and which mailboxes it enrolled in the hosted pool ([Warmbly Cloud](/guides/warmbly-cloud/)), linked self-hosted instances on a cloud workspace, Google Workspace and Microsoft 365 admin grants, live sessions, in-flight OAuth handshakes and the organization's wrapped data key. Reconnect to Warmbly Cloud and make each admin grant again after the move.
|
||||
|
||||
[Mailbox imports](/guides/mailbox-import/) do not travel either, finished or running: an import is work the source instance is doing, and its rows hold credentials in flight. The mailboxes an import connected are ordinary mailboxes and move with the Workspace group, and the saved column mappings come too, so the same file maps itself on the destination. Let a running import finish before exporting: rows it has not connected yet are not in the archive.
|
||||
|
||||
@@ -173,17 +173,12 @@ func (h *Handler) AdminCreateTester(c *gin.Context) {
|
||||
if orgName == "" {
|
||||
orgName = "Tester workspace"
|
||||
}
|
||||
org, oerr := h.OrganizationService.Create(c.Request.Context(), created.ID, orgName, "")
|
||||
org, oerr := h.OrganizationService.CreateTesterWorkspace(c.Request.Context(), created.ID, *adminID, orgName, reason, passwordExpiresAt)
|
||||
if oerr != nil {
|
||||
h.undoHalfMadeTester(c, created.ID, oerr)
|
||||
return
|
||||
}
|
||||
orgID = org.ID
|
||||
if h.TrialService != nil {
|
||||
// Best effort: without it the workspace has no subscription row and
|
||||
// reads as unpaid, which is recoverable from the admin panel.
|
||||
_ = h.TrialService.StartFreeTrialWithOrg(c.Request.Context(), created.ID, org.ID)
|
||||
}
|
||||
}
|
||||
|
||||
entry := map[string]any{
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/api/middleware"
|
||||
"github.com/warmbly/warmbly/internal/app/organization"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
type testerUsers struct {
|
||||
repository.UserRepository
|
||||
userID uuid.UUID
|
||||
until time.Time
|
||||
}
|
||||
|
||||
func (r *testerUsers) GetUserByEmail(context.Context, string) (*models.User, error) { return nil, nil }
|
||||
|
||||
func (r *testerUsers) CreateExemptUser(_ context.Context, email *mail.Address, _, _ string, _ *uuid.UUID, until time.Time) (*models.User, error) {
|
||||
r.until = until
|
||||
return &models.User{ID: r.userID, Email: email.Address}, nil
|
||||
}
|
||||
|
||||
type testerWorkspaces struct {
|
||||
organization.OrganizationService
|
||||
orgID, actor, roleID uuid.UUID
|
||||
until time.Time
|
||||
created, joined bool
|
||||
}
|
||||
|
||||
func (s *testerWorkspaces) CreateTesterWorkspace(_ context.Context, _, actor uuid.UUID, _, _ string, until time.Time) (*models.Organization, *errx.Error) {
|
||||
s.created, s.actor, s.until = true, actor, until
|
||||
return &models.Organization{ID: s.orgID, Category: models.OrganizationCategoryTest}, nil
|
||||
}
|
||||
|
||||
func (s *testerWorkspaces) AttachTester(_ context.Context, orgID, _, actor, roleID uuid.UUID) (*models.OrganizationMember, *errx.Error) {
|
||||
s.joined, s.orgID, s.actor, s.roleID = true, orgID, actor, roleID
|
||||
return &models.OrganizationMember{OrganizationID: orgID}, nil
|
||||
}
|
||||
|
||||
func TestAdminCreateTesterOnlyGrantsDedicatedWorkspaces(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, existing := range []bool{false, true} {
|
||||
actor, orgID, roleID := uuid.New(), uuid.New(), uuid.New()
|
||||
users := &testerUsers{userID: uuid.New()}
|
||||
orgs := &testerWorkspaces{orgID: orgID}
|
||||
h := &Handler{UserRepo: users, OrganizationService: orgs}
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) { c.Set(middleware.AdminUserIDKey, actor) })
|
||||
r.POST("/testers", h.AdminCreateTester)
|
||||
req := adminCreateTesterRequest{Email: "reviewer@example.test", Reason: "OAuth review", PasswordDays: 7}
|
||||
if existing {
|
||||
req.OrgID, req.RoleID = &orgID, &roleID
|
||||
}
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/testers", strings.NewReader(string(raw))))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("create tester existing=%v: HTTP %d", existing, w.Code)
|
||||
}
|
||||
var response adminCreateTesterResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Joined != existing || orgs.created == existing || orgs.joined != existing || orgs.actor != actor {
|
||||
t.Fatalf("wrong provisioning path for existing=%v", existing)
|
||||
}
|
||||
if existing && orgs.roleID != roleID {
|
||||
t.Fatal("existing workspace role changed")
|
||||
}
|
||||
if !existing && !orgs.until.Equal(users.until) {
|
||||
t.Fatal("test entitlement did not use the reviewer password expiry")
|
||||
}
|
||||
if !response.PasswordExpiresAt.Equal(users.until) || !strings.HasPrefix(response.Password, "Tester-") {
|
||||
t.Fatal("reviewer credentials response is incomplete")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package feature
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/errx"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func TestTestPlanAccessEndsWithReviewerExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
orgID := uuid.New()
|
||||
granted := time.Now().Add(-time.Hour)
|
||||
for _, expired := range []bool{false, true} {
|
||||
until := time.Now().Add(time.Hour)
|
||||
if expired {
|
||||
until = time.Now().Add(-time.Minute)
|
||||
}
|
||||
sub := &models.Subscription{PlanID: uuid.New(), ManagedAt: &granted, ManagedUntil: &until, ManagedPlanID: &models.TestPlanID}
|
||||
gate := &featureGateService{subRepo: stubSubs{sub: sub}}
|
||||
for name, check := range map[string]func(context.Context, uuid.UUID) (bool, *errx.Error){
|
||||
"product": gate.IsPaidOrganization,
|
||||
"campaigns": gate.CanSendCampaignEmail,
|
||||
"premium pool": gate.HasPremiumWarmup,
|
||||
"Unibox": gate.CanUseUnibox,
|
||||
"writing assistant": gate.CanUseWritingAssistant,
|
||||
"inbox agent": gate.CanUseInboxAgent,
|
||||
} {
|
||||
allowed, xerr := check(ctx, orgID)
|
||||
if xerr != nil || allowed == expired {
|
||||
t.Errorf("%s with expired=%v: allowed=%v, error=%v", name, expired, allowed, xerr)
|
||||
}
|
||||
}
|
||||
if allowed, xerr := gate.CanUseWarmup(ctx, orgID); xerr != nil || !allowed {
|
||||
t.Error("expiry must not remove normal free-pool warmup access")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package organization
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
type testerCreateRepo struct {
|
||||
createRepo
|
||||
grantErr error
|
||||
deleted bool
|
||||
until time.Time
|
||||
actor uuid.UUID
|
||||
}
|
||||
|
||||
func (r *testerCreateRepo) ProvisionTesterWorkspace(_ context.Context, _, _, actor uuid.UUID, _ string, until time.Time) error {
|
||||
r.actor, r.until = actor, until
|
||||
return r.grantErr
|
||||
}
|
||||
|
||||
func (r *testerCreateRepo) Delete(context.Context, uuid.UUID) error {
|
||||
r.deleted = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCreateTesterWorkspaceRequiresACompleteGrant(t *testing.T) {
|
||||
for _, fail := range []bool{false, true} {
|
||||
r := &testerCreateRepo{}
|
||||
if fail {
|
||||
r.grantErr = errors.New("credit ledger unavailable")
|
||||
}
|
||||
s := &organizationService{orgRepo: r, userRepo: createUsers{}}
|
||||
actor := uuid.New()
|
||||
until := time.Now().Add(time.Hour)
|
||||
org, xerr := s.CreateTesterWorkspace(context.Background(), uuid.New(), actor, "Reviewer workspace", "OAuth review", until)
|
||||
if fail {
|
||||
if xerr == nil || org != nil || !r.deleted {
|
||||
t.Fatalf("failed grant left a usable workspace: %+v, %v, deleted=%v", org, xerr, r.deleted)
|
||||
}
|
||||
} else if xerr != nil || org.Category != models.OrganizationCategoryTest || !r.until.Equal(until) || r.actor != actor {
|
||||
t.Fatalf("tester grant not complete: %+v, %v", org, xerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ type OperatorNotifier interface {
|
||||
|
||||
// OrganizationService defines the interface for organization management
|
||||
type OrganizationService interface {
|
||||
CreateTesterWorkspace(ctx context.Context, userID, adminID uuid.UUID, name, reason string, until time.Time) (*models.Organization, *errx.Error)
|
||||
// WireAuthPolicy attaches the deployment auth policy after construction.
|
||||
WireAuthPolicy(p *config.AuthPolicy)
|
||||
|
||||
@@ -324,6 +325,7 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name
|
||||
}
|
||||
|
||||
org := &models.Organization{
|
||||
Category: models.OrganizationCategoryStandard,
|
||||
ID: uuid.New(),
|
||||
Name: name,
|
||||
OwnerUserID: userID,
|
||||
@@ -393,6 +395,26 @@ func (s *organizationService) Create(ctx context.Context, userID uuid.UUID, name
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (s *organizationService) CreateTesterWorkspace(ctx context.Context, userID, adminID uuid.UUID, name, reason string, until time.Time) (*models.Organization, *errx.Error) {
|
||||
if !until.After(time.Now()) || strings.TrimSpace(reason) == "" {
|
||||
return nil, errx.New(errx.BadRequest, "test access needs a reason and a future expiry")
|
||||
}
|
||||
org, xerr := s.Create(ctx, userID, name, "")
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
if err := s.orgRepo.ProvisionTesterWorkspace(ctx, org.ID, userID, adminID, reason, until); err != nil {
|
||||
errs.CaptureException(err)
|
||||
if derr := s.orgRepo.Delete(ctx, org.ID); derr != nil {
|
||||
errs.CaptureException(derr)
|
||||
return nil, errx.New(errx.Internal, "could not provision or remove the test workspace; review it in the admin panel")
|
||||
}
|
||||
return nil, errx.New(errx.Internal, "could not provision the test workspace")
|
||||
}
|
||||
org.Category = models.OrganizationCategoryTest
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// Get retrieves an organization by ID
|
||||
func (s *organizationService) Get(ctx context.Context, orgID uuid.UUID) (*models.Organization, *errx.Error) {
|
||||
org, err := s.orgRepo.GetByID(ctx, orgID)
|
||||
|
||||
@@ -38,3 +38,9 @@ func TestOrgRowStripsTheOperatorVerdict(t *testing.T) {
|
||||
t.Errorf("manifest carries %d columns, want only the ordinary one", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveCannotSetTestWorkspaceCategory(t *testing.T) {
|
||||
if !orgMergeExcluded["category"] {
|
||||
t.Fatal("workspace imports must not overwrite the instance's test classification")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,6 +677,7 @@ var orgMergeExcluded = func() map[string]bool {
|
||||
out := map[string]bool{
|
||||
"id": true,
|
||||
"owner_user_id": true,
|
||||
"category": true,
|
||||
"slug": true,
|
||||
"created_at": true,
|
||||
"deletion_scheduled_at": true,
|
||||
|
||||
@@ -13,7 +13,8 @@ func (s *userService) GetUser(ctx context.Context, userID uuid.UUID) (*models.Us
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u != nil {
|
||||
// Onboarding may have been completed by an operator or a migration.
|
||||
if u != nil && u.OnboardingCompletedAt != nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/infrastructure/cache"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
"github.com/warmbly/warmbly/internal/repository"
|
||||
)
|
||||
|
||||
type onboardedUserRepo struct {
|
||||
repository.UserRepository
|
||||
user *models.User
|
||||
reads int
|
||||
}
|
||||
|
||||
func (r *onboardedUserRepo) GetUser(context.Context, uuid.UUID) (*models.User, error) {
|
||||
r.reads++
|
||||
return r.user, nil
|
||||
}
|
||||
|
||||
func TestLiveUserRefreshesCachedOnboardingAfterBackfill(t *testing.T) {
|
||||
url := os.Getenv("WARMBLY_TEST_REDIS")
|
||||
if url == "" {
|
||||
t.Skip("WARMBLY_TEST_REDIS not set")
|
||||
}
|
||||
store, err := cache.New(url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
ctx := context.Background()
|
||||
userID := uuid.New()
|
||||
t.Cleanup(func() { store.Del(ctx, CacheKey(userID)) })
|
||||
now := time.Now()
|
||||
repo := &onboardedUserRepo{user: &models.User{ID: userID, OnboardingCompletedAt: &now}}
|
||||
svc := &userService{cache: store, userRepository: repo}
|
||||
if xerr := svc.SaveUser(ctx, &models.User{ID: userID}); xerr != nil {
|
||||
t.Fatal(xerr)
|
||||
}
|
||||
for range 2 {
|
||||
u, xerr := svc.GetUser(ctx, userID)
|
||||
if xerr != nil || u.OnboardingCompletedAt == nil {
|
||||
t.Fatalf("cached onboarding was not refreshed: %v", xerr)
|
||||
}
|
||||
}
|
||||
if repo.reads != 1 {
|
||||
t.Fatalf("onboarded users should still be cached, repository reads=%d", repo.reads)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,20 @@ package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGoogleMailboxOAuthUsesBoxCredentials(t *testing.T) {
|
||||
t.Setenv("BOX_GOOGLE_CLIENT_ID", "mailbox-client")
|
||||
t.Setenv("BOX_GOOGLE_CLIENT_SECRET", "mailbox-secret")
|
||||
t.Setenv("GOOGLE_CLIENT_ID", "sso-client")
|
||||
t.Setenv("GOOGLE_CLIENT_SECRET", "sso-secret")
|
||||
conf := GoogleOauth2Inbox("https://api.example.test")
|
||||
if conf.ClientID != "mailbox-client" || conf.ClientSecret != "mailbox-secret" {
|
||||
t.Fatal("mailbox OAuth must use BOX credentials, not dashboard SSO credentials")
|
||||
}
|
||||
if conf.RedirectURL != "https://api.example.test/addresses/google/callback" {
|
||||
t.Fatalf("unexpected mailbox callback: %s", conf.RedirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoogleOAuthConnect(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name, flag, id, secret string
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
UPDATE subscriptions SET managed_at = NULL, managed_by = NULL, managed_reason = NULL,
|
||||
managed_until = NULL, managed_plan_id = NULL, updated_at = now()
|
||||
WHERE managed_plan_id = '00000000-0000-0000-0000-0000000000e1';
|
||||
|
||||
DELETE FROM plans p WHERE p.id = '00000000-0000-0000-0000-0000000000e1'
|
||||
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.plan_id = p.id);
|
||||
|
||||
ALTER TABLE organizations DROP COLUMN category;
|
||||
@@ -0,0 +1,58 @@
|
||||
ALTER TABLE organizations ADD COLUMN category text NOT NULL DEFAULT 'standard'
|
||||
CHECK (category IN ('standard', 'test'));
|
||||
|
||||
INSERT INTO plans (id, name, max_contacts, daily_emails, ai_generation, account_limit,
|
||||
price, discounted_price, duration_id, savings, public, monthly_credits,
|
||||
max_campaigns, max_active_campaigns, max_team_members, max_email_accounts, daily_campaign_limit)
|
||||
VALUES ('00000000-0000-0000-0000-0000000000e1', 'Test', 10000, 1000, true, 10,
|
||||
0, 0, (SELECT id FROM durations WHERE title = 'month'), 0, false, 100,
|
||||
100, 20, 10, 10, 100);
|
||||
|
||||
UPDATE users u SET password_expires_at = a.created_at + interval '30 days', updated_at = now()
|
||||
FROM admin_audit_logs a
|
||||
WHERE a.action = 'create_tester' AND a.target_id = u.id
|
||||
AND u.login_code_exempt AND u.password_expires_at IS NULL;
|
||||
|
||||
UPDATE users SET onboarding_completed_at = COALESCE(login_code_exempt_at, created_at), updated_at = now()
|
||||
WHERE onboarding_completed_at IS NULL AND login_code_exempt AND password_expires_at IS NOT NULL;
|
||||
|
||||
UPDATE organizations o SET category = 'test', updated_at = now()
|
||||
FROM users u
|
||||
WHERE o.owner_user_id = u.id
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM admin_audit_logs a
|
||||
WHERE a.action = 'create_tester' AND a.target_id = u.id
|
||||
AND a.details->>'organization_id' = o.id::text
|
||||
AND COALESCE(a.details->>'joined_existing', 'false') = 'false'
|
||||
);
|
||||
|
||||
INSERT INTO subscriptions (user_id, organization_id, plan_id, stripe_customer_id,
|
||||
managed_at, managed_by, managed_reason, managed_until, managed_plan_id)
|
||||
SELECT u.id, o.id, '00000000-0000-0000-0000-000000000001', '',
|
||||
now(), u.login_code_exempt_by, u.login_code_exempt_reason, u.password_expires_at,
|
||||
'00000000-0000-0000-0000-0000000000e1'
|
||||
FROM organizations o JOIN users u ON u.id = o.owner_user_id
|
||||
WHERE o.category = 'test' AND u.login_code_exempt AND u.password_expires_at > now()
|
||||
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.organization_id = o.id);
|
||||
|
||||
UPDATE subscriptions s SET managed_at = now(), managed_by = u.login_code_exempt_by,
|
||||
managed_reason = u.login_code_exempt_reason, managed_until = u.password_expires_at,
|
||||
managed_plan_id = '00000000-0000-0000-0000-0000000000e1', updated_at = now()
|
||||
FROM organizations o JOIN users u ON u.id = o.owner_user_id
|
||||
WHERE s.organization_id = o.id AND o.category = 'test' AND u.login_code_exempt AND u.password_expires_at > now()
|
||||
AND s.managed_at IS NULL AND s.stripe_subscription_id IS NULL;
|
||||
|
||||
WITH granted AS (
|
||||
INSERT INTO credit_ledger (org_id, balance)
|
||||
SELECT o.id, p.monthly_credits
|
||||
FROM organizations o JOIN subscriptions s ON s.organization_id = o.id
|
||||
JOIN plans p ON p.id = s.managed_plan_id
|
||||
WHERE o.category = 'test' AND p.id = '00000000-0000-0000-0000-0000000000e1'
|
||||
AND s.managed_until > now()
|
||||
AND NOT EXISTS (SELECT 1 FROM credit_ledger_transactions t WHERE t.idempotency_key = o.id::text || ':tester_grant')
|
||||
ON CONFLICT (org_id) DO UPDATE SET balance = credit_ledger.balance + EXCLUDED.balance, updated_at = now()
|
||||
RETURNING org_id, balance, purchased_balance
|
||||
)
|
||||
INSERT INTO credit_ledger_transactions (org_id, amount, reason, balance_after, purchased_balance_after, idempotency_key)
|
||||
SELECT org_id, p.monthly_credits, 'tester_grant', balance, purchased_balance, org_id::text || ':tester_grant'
|
||||
FROM granted CROSS JOIN plans p WHERE p.id = '00000000-0000-0000-0000-0000000000e1';
|
||||
+11
-10
@@ -717,16 +717,17 @@ type AdminOrgSearch struct {
|
||||
// summary state for the table (owner, counts, deletion status) without
|
||||
// joining to plans or subscriptions — those land on the detail endpoint.
|
||||
type AdminOrgListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug *string `json:"slug,omitempty"`
|
||||
OwnerUserID uuid.UUID `json:"owner_user_id"`
|
||||
OwnerEmail string `json:"owner_email"`
|
||||
OwnerFirstName string `json:"owner_first_name"`
|
||||
OwnerLastName string `json:"owner_last_name"`
|
||||
OwnerBannedAt *time.Time `json:"owner_banned_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletionScheduledFor *time.Time `json:"deletion_scheduled_for,omitempty"`
|
||||
Category OrganizationCategory `json:"category"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug *string `json:"slug,omitempty"`
|
||||
OwnerUserID uuid.UUID `json:"owner_user_id"`
|
||||
OwnerEmail string `json:"owner_email"`
|
||||
OwnerFirstName string `json:"owner_first_name"`
|
||||
OwnerLastName string `json:"owner_last_name"`
|
||||
OwnerBannedAt *time.Time `json:"owner_banned_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletionScheduledFor *time.Time `json:"deletion_scheduled_for,omitempty"`
|
||||
|
||||
// Resource counts. Cheap enough to inline on the list query so the
|
||||
// table can show usage at a glance without an extra round-trip.
|
||||
|
||||
@@ -6,15 +6,23 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type OrganizationCategory string
|
||||
|
||||
const (
|
||||
OrganizationCategoryStandard OrganizationCategory = "standard"
|
||||
OrganizationCategoryTest OrganizationCategory = "test"
|
||||
)
|
||||
|
||||
// Organization represents a multi-user organization/workspace
|
||||
type Organization struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug *string `json:"slug,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
OwnerUserID uuid.UUID `json:"owner_user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Category OrganizationCategory `json:"category"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug *string `json:"slug,omitempty"`
|
||||
AvatarURL *string `json:"avatar_url,omitempty"`
|
||||
OwnerUserID uuid.UUID `json:"owner_user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Soft-delete window. When DeletionScheduledFor is non-nil the
|
||||
// organization is "pending deletion" and will be hard-deleted at
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
|
||||
type Duration string
|
||||
|
||||
var TestPlanID = uuid.MustParse("00000000-0000-0000-0000-0000000000e1")
|
||||
|
||||
const (
|
||||
DurationMonth Duration = "month"
|
||||
DurationYear Duration = "year"
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
// OrganizationRepository defines the interface for organization data access
|
||||
type OrganizationRepository interface {
|
||||
ProvisionTesterWorkspace(ctx context.Context, orgID, ownerID, adminID uuid.UUID, reason string, until time.Time) error
|
||||
// Organization CRUD
|
||||
Create(ctx context.Context, org *models.Organization) error
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*models.Organization, error)
|
||||
@@ -159,7 +160,7 @@ func (r *organizationRepository) GetByID(ctx context.Context, id uuid.UUID) (*mo
|
||||
deletion_scheduled_at, deletion_scheduled_for,
|
||||
presence_show_online, presence_show_activity,
|
||||
product_description, icp_notes, voice_profile, inbox_agent_enabled,
|
||||
assistant_shared_history, timezone
|
||||
assistant_shared_history, timezone, category
|
||||
FROM organizations WHERE id = $1
|
||||
`
|
||||
return r.scanOrganization(ctx, query, id)
|
||||
@@ -172,7 +173,7 @@ func (r *organizationRepository) GetBySlug(ctx context.Context, slug string) (*m
|
||||
deletion_scheduled_at, deletion_scheduled_for,
|
||||
presence_show_online, presence_show_activity,
|
||||
product_description, icp_notes, voice_profile, inbox_agent_enabled,
|
||||
assistant_shared_history, timezone
|
||||
assistant_shared_history, timezone, category
|
||||
FROM organizations WHERE slug = $1
|
||||
`
|
||||
return r.scanOrganization(ctx, query, slug)
|
||||
@@ -181,7 +182,7 @@ func (r *organizationRepository) GetBySlug(ctx context.Context, slug string) (*m
|
||||
func (r *organizationRepository) scanOrganization(ctx context.Context, query string, args ...interface{}) (*models.Organization, error) {
|
||||
row := r.db.QueryRow(ctx, query, args...)
|
||||
var org models.Organization
|
||||
err := row.Scan(&org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt, &org.DeletionScheduledAt, &org.DeletionScheduledFor, &org.PresenceShowOnline, &org.PresenceShowActivity, &org.ProductDescription, &org.ICPNotes, &org.VoiceProfile, &org.InboxAgentEnabled, &org.AssistantSharedHistory, &org.Timezone)
|
||||
err := row.Scan(&org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt, &org.DeletionScheduledAt, &org.DeletionScheduledFor, &org.PresenceShowOnline, &org.PresenceShowActivity, &org.ProductDescription, &org.ICPNotes, &org.VoiceProfile, &org.InboxAgentEnabled, &org.AssistantSharedHistory, &org.Timezone, &org.Category)
|
||||
if err == pgx.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -228,7 +229,7 @@ func (r *organizationRepository) GetUserOrganizations(ctx context.Context, userI
|
||||
om.id, om.organization_id, om.user_id, om.role, om.permissions,
|
||||
om.invited_by, om.invited_at, om.accepted_at,
|
||||
o.id, o.name, o.slug, o.avatar_url, o.owner_user_id, o.created_at, o.updated_at,
|
||||
o.deletion_scheduled_at, o.deletion_scheduled_for
|
||||
o.deletion_scheduled_at, o.deletion_scheduled_for, o.category
|
||||
FROM organization_members om
|
||||
JOIN organizations o ON o.id = om.organization_id
|
||||
WHERE om.user_id = $1
|
||||
@@ -248,7 +249,7 @@ func (r *organizationRepository) GetUserOrganizations(ctx context.Context, userI
|
||||
&m.ID, &m.OrganizationID, &m.UserID, &m.Role, &m.Permissions,
|
||||
&m.InvitedBy, &m.InvitedAt, &m.AcceptedAt,
|
||||
&org.ID, &org.Name, &org.Slug, &org.AvatarURL, &org.OwnerUserID, &org.CreatedAt, &org.UpdatedAt,
|
||||
&org.DeletionScheduledAt, &org.DeletionScheduledFor,
|
||||
&org.DeletionScheduledAt, &org.DeletionScheduledFor, &org.Category,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -266,7 +267,7 @@ func (r *organizationRepository) GetUserDefaultOrganization(ctx context.Context,
|
||||
deletion_scheduled_at, deletion_scheduled_for,
|
||||
presence_show_online, presence_show_activity,
|
||||
product_description, icp_notes, voice_profile, inbox_agent_enabled,
|
||||
assistant_shared_history, timezone
|
||||
assistant_shared_history, timezone, category
|
||||
FROM organizations WHERE owner_user_id = $1
|
||||
ORDER BY created_at ASC LIMIT 1
|
||||
`
|
||||
@@ -769,7 +770,7 @@ const adminOrgListColumns = `
|
||||
(SELECT COUNT(*) FROM campaigns c WHERE c.organization_id = o.id) AS campaign_count,
|
||||
(SELECT COUNT(*) FROM campaigns c WHERE c.organization_id = o.id AND c.status = 'active') AS active_campaigns,
|
||||
o.risk_state,
|
||||
oa.utm_source, oa.utm_medium, oa.utm_campaign, oa.landing_path`
|
||||
oa.utm_source, oa.utm_medium, oa.utm_campaign, oa.landing_path, o.category`
|
||||
|
||||
// adminOrgAcquisitionJoin brings in the signup channel. LEFT because most
|
||||
// workspaces have no row: a direct signup carries nothing to record.
|
||||
@@ -988,6 +989,7 @@ func (r *organizationRepository) SearchOrganizationsForAdmin(ctx context.Context
|
||||
&item.MemberCount, &item.EmailAccountCount, &item.CampaignCount, &item.ActiveCampaigns,
|
||||
&item.RiskState,
|
||||
&item.UTMSource, &item.UTMMedium, &item.UTMCampaign, &item.LandingPath,
|
||||
&item.Category,
|
||||
&planName, &planPublic, &isEnterprise,
|
||||
&managedAt, &managedReason, &managedUntil,
|
||||
); err != nil {
|
||||
@@ -1051,6 +1053,7 @@ func (r *organizationRepository) GetOrganizationAdminDetail(ctx context.Context,
|
||||
&detail.MemberCount, &detail.EmailAccountCount, &detail.CampaignCount, &detail.ActiveCampaigns,
|
||||
&detail.RiskState,
|
||||
&detail.UTMSource, &detail.UTMMedium, &detail.UTMCampaign, &detail.LandingPath,
|
||||
&detail.Category,
|
||||
&detail.UpdatedAt, &detail.DeletionScheduledAt,
|
||||
&detail.PlanName, &detail.SubscriptionStatus, &isEnterprise, &detail.CurrentPeriodEnd, &detail.TrialEnd,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
// ProvisionTesterWorkspace grants access only to a dedicated reviewer's workspace.
|
||||
func (r *organizationRepository) ProvisionTesterWorkspace(ctx context.Context, orgID, ownerID, adminID uuid.UUID, reason string, until time.Time) error {
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
var id uuid.UUID
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT o.id FROM organizations o JOIN users u ON u.id = o.owner_user_id
|
||||
WHERE o.id = $1 AND o.owner_user_id = $2 AND u.login_code_exempt
|
||||
AND u.password_expires_at = $3 AND u.password_expires_at > now()
|
||||
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.organization_id = o.id)
|
||||
FOR UPDATE OF o`, orgID, ownerID, until).Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `UPDATE organizations SET category = 'test', updated_at = now() WHERE id = $1`, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO subscriptions (user_id, organization_id, plan_id, stripe_customer_id,
|
||||
managed_at, managed_by, managed_reason, managed_until, managed_plan_id)
|
||||
VALUES ($1, $2, '00000000-0000-0000-0000-000000000001', '', now(), $3, $4, $5, $6)`,
|
||||
ownerID, orgID, adminID, reason, until, models.TestPlanID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var allowance int
|
||||
if err := tx.QueryRow(ctx, `SELECT monthly_credits FROM plans WHERE id = $1`, models.TestPlanID).Scan(&allowance); err != nil {
|
||||
return err
|
||||
}
|
||||
if allowance <= 0 {
|
||||
return errors.New("the test plan needs a credit allowance")
|
||||
}
|
||||
var balance, purchased int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO credit_ledger (org_id, balance) VALUES ($1, $2)
|
||||
ON CONFLICT (org_id) DO UPDATE SET balance = credit_ledger.balance + EXCLUDED.balance, updated_at = now()
|
||||
RETURNING balance, purchased_balance`, orgID, allowance).Scan(&balance, &purchased); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := insertTxn(ctx, tx, orgID, allowance, "tester_grant", "", 0, balance, 0, purchased, scopeKey(orgID, "tester_grant")); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
@@ -393,18 +393,19 @@ func (r *userRepository) CreateExemptUser(ctx context.Context, email *mail.Addre
|
||||
firstName := displayname.FromEmail(address)
|
||||
now := time.Now()
|
||||
if _, ierr := tx.Exec(ctx, `
|
||||
INSERT INTO users (id, email, password_hash, first_name, last_name, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, '', $5, $5)`,
|
||||
INSERT INTO users (id, email, password_hash, first_name, last_name, created_at, updated_at, onboarding_completed_at)
|
||||
VALUES ($1, $2, $3, $4, '', $5, $5, $5)`,
|
||||
id, address, passwordHash, firstName, now); ierr != nil {
|
||||
return nil, ierr
|
||||
}
|
||||
created := &models.User{
|
||||
ID: id,
|
||||
FirstName: firstName,
|
||||
Email: address,
|
||||
Roles: make([]uuid.UUID, 0),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
OnboardingCompletedAt: &now,
|
||||
ID: id,
|
||||
FirstName: firstName,
|
||||
Email: address,
|
||||
Roles: make([]uuid.UUID, 0),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if _, eerr := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
@@ -427,6 +428,12 @@ func (r *userRepository) CreateExemptUser(ctx context.Context, email *mail.Addre
|
||||
func (r *userRepository) RevokeTester(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var cleared bool
|
||||
err := r.DB.QueryRow(ctx, `
|
||||
WITH ended_grants AS (
|
||||
UPDATE subscriptions s SET managed_until = now(), updated_at = now()
|
||||
FROM organizations o
|
||||
WHERE s.organization_id = o.id AND o.owner_user_id = $1
|
||||
AND o.category = 'test' AND s.managed_plan_id = $2
|
||||
)
|
||||
UPDATE users u
|
||||
SET login_code_exempt = false,
|
||||
login_code_exempt_reason = NULL,
|
||||
@@ -438,7 +445,7 @@ func (r *userRepository) RevokeTester(ctx context.Context, id uuid.UUID) (bool,
|
||||
updated_at = now()
|
||||
FROM (SELECT id, password_expires_at IS NOT NULL AS tester FROM users WHERE id = $1 FOR UPDATE) old
|
||||
WHERE u.id = old.id
|
||||
RETURNING old.tester`, id).Scan(&cleared)
|
||||
RETURNING old.tester`, id, models.TestPlanID).Scan(&cleared)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, ErrUserNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/mail"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/warmbly/warmbly/internal/models"
|
||||
)
|
||||
|
||||
func TestLiveTesterWorkspaceProvisioning(t *testing.T) {
|
||||
db, pool := liveContactDB(t)
|
||||
requireSchemaVersion(t, pool, 264)
|
||||
ctx := context.Background()
|
||||
users := NewUserRepostory(db, nil)
|
||||
orgs := NewOrganizationRepository(pool)
|
||||
until := time.Now().Add(24 * time.Hour).Truncate(time.Microsecond)
|
||||
u, err := users.CreateExemptUser(ctx, &mail.Address{Address: uuid.NewString() + "@example.test"}, "hash", "review", nil, until)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
slug := uuid.NewString()
|
||||
org := &models.Organization{ID: uuid.New(), OwnerUserID: u.ID, Name: "Test workspace", Slug: &slug}
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, org.ID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, u.ID)
|
||||
})
|
||||
if u.OnboardingCompletedAt == nil {
|
||||
t.Fatal("created reviewer must be onboarded")
|
||||
}
|
||||
stored, err := users.GetUser(ctx, u.ID)
|
||||
if err != nil || stored.OnboardingCompletedAt == nil {
|
||||
t.Fatalf("stored reviewer must be onboarded: %v", err)
|
||||
}
|
||||
if err := orgs.Create(ctx, org); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An invalid grant actor fails after categorization; the whole grant must roll back.
|
||||
if err := orgs.ProvisionTesterWorkspace(ctx, org.ID, u.ID, uuid.New(), "review", until); err == nil {
|
||||
t.Fatal("expected a foreign-key failure")
|
||||
}
|
||||
rolledBack, err := orgs.GetByID(ctx, org.ID)
|
||||
if err != nil || rolledBack.Category != models.OrganizationCategoryStandard {
|
||||
t.Fatalf("failed provisioning changed category: %v, %v", rolledBack, err)
|
||||
}
|
||||
var credits int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM credit_ledger WHERE org_id = $1`, org.ID).Scan(&credits); err != nil || credits != 0 {
|
||||
t.Fatalf("failed provisioning minted credits: %d, %v", credits, err)
|
||||
}
|
||||
if err := orgs.ProvisionTesterWorkspace(ctx, org.ID, u.ID, u.ID, "review", until); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provisioned, err := orgs.GetByID(ctx, org.ID)
|
||||
if err != nil || provisioned.Category != models.OrganizationCategoryTest {
|
||||
t.Fatalf("test category missing: %v, %v", provisioned, err)
|
||||
}
|
||||
adminDetail, err := orgs.GetOrganizationAdminDetail(ctx, org.ID)
|
||||
if err != nil || adminDetail.Category != models.OrganizationCategoryTest {
|
||||
t.Fatalf("admin category missing: %v, %v", adminDetail, err)
|
||||
}
|
||||
list, err := orgs.SearchOrganizationsForAdmin(ctx, &models.AdminOrgSearch{Query: slug, Limit: 10})
|
||||
if err != nil || len(list.Data) != 1 || list.Data[0].Category != models.OrganizationCategoryTest {
|
||||
t.Fatalf("admin list category missing: %v, %v", list, err)
|
||||
}
|
||||
subs := NewSubscriptionRepository(pool)
|
||||
sub, err := subs.GetByOrganizationID(ctx, org.ID)
|
||||
if err != nil || !sub.HasProductPlan() || sub.EffectivePlanID() != models.TestPlanID || !sub.ManagedUntil.Equal(until) {
|
||||
t.Fatalf("test plan or expiry missing: %v, %v", sub, err)
|
||||
}
|
||||
if err := orgs.ProvisionTesterWorkspace(ctx, org.ID, u.ID, u.ID, "retry", until); err == nil {
|
||||
t.Fatal("reprovisioning an existing workspace must fail")
|
||||
}
|
||||
var txns int
|
||||
if err := pool.QueryRow(ctx, `SELECT balance FROM credit_ledger WHERE org_id = $1`, org.ID).Scan(&credits); err != nil || credits != 100 {
|
||||
t.Fatalf("test credits = %d, %v", credits, err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM credit_ledger_transactions WHERE org_id = $1 AND reason = 'tester_grant'`, org.ID).Scan(&txns); err != nil || txns != 1 {
|
||||
t.Fatalf("test grant audit = %d, %v", txns, err)
|
||||
}
|
||||
if cleared, err := users.RevokeTester(ctx, u.ID); err != nil || !cleared {
|
||||
t.Fatalf("revoke = %v, %v", cleared, err)
|
||||
}
|
||||
sub, err = subs.GetByOrganizationID(ctx, org.ID)
|
||||
if err != nil || sub.HasProductPlan() {
|
||||
t.Fatalf("revocation left paid access: %v, %v", sub, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveTesterWorkspaceBackfill(t *testing.T) {
|
||||
_, pool := liveContactDB(t)
|
||||
requireSchemaVersion(t, pool, 264)
|
||||
ctx := context.Background()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := tx.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
readMigration := func(direction string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("../infrastructure/db/migrations/000264_tester_workspaces." + direction + ".sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
exec(readMigration("down"))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
joined, expired, revoked, cli, legacy, paid, managed, missingSubscription bool
|
||||
category string
|
||||
grant, onboarded bool
|
||||
}{
|
||||
{name: "dedicated", category: "test", grant: true, onboarded: true},
|
||||
{name: "dedicated without subscription", missingSubscription: true, category: "test", grant: true, onboarded: true},
|
||||
{name: "existing", joined: true, category: "standard", onboarded: true},
|
||||
{name: "expired", expired: true, category: "test", onboarded: true},
|
||||
{name: "revoked", revoked: true, category: "test"},
|
||||
{name: "CLI exemption", cli: true, legacy: true, category: "standard"},
|
||||
{name: "legacy dedicated", legacy: true, category: "test", grant: true, onboarded: true},
|
||||
{name: "legacy expired", legacy: true, expired: true, category: "test", onboarded: true},
|
||||
{name: "Stripe workspace", paid: true, category: "test", onboarded: true},
|
||||
{name: "managed workspace", managed: true, category: "test", onboarded: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
exec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := tx.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
userID, orgID := uuid.New(), uuid.New()
|
||||
until := time.Now().Add(time.Hour)
|
||||
if tc.expired {
|
||||
until = time.Now().Add(-time.Hour)
|
||||
}
|
||||
var expiry *time.Time
|
||||
if !tc.legacy && !tc.revoked {
|
||||
expiry = &until
|
||||
}
|
||||
exec(`INSERT INTO users (id, first_name, last_name, email, password_hash, login_code_exempt, login_code_exempt_reason, login_code_exempt_at, password_expires_at)
|
||||
VALUES ($1, 'Reviewer', '', $2, 'hash', $3, 'review', now(), $4)`, userID, userID.String()+"@example.test", !tc.revoked, expiry)
|
||||
exec(`INSERT INTO organizations (id, name, owner_user_id) VALUES ($1, 'Reviewer workspace', $2)`, orgID, userID)
|
||||
if !tc.cli {
|
||||
exec(`INSERT INTO admin_audit_logs (admin_user_id, action, target_type, target_id, details)
|
||||
VALUES ($1, 'create_tester', 'user', $1, jsonb_build_object('organization_id', $2::text, 'joined_existing', $3::boolean))`, userID, orgID.String(), tc.joined)
|
||||
}
|
||||
if tc.legacy {
|
||||
exec(`UPDATE admin_audit_logs SET details = details - 'joined_existing' WHERE target_id = $1`, userID)
|
||||
if tc.expired {
|
||||
exec(`UPDATE admin_audit_logs SET created_at = now() - interval '40 days' WHERE target_id = $1`, userID)
|
||||
}
|
||||
}
|
||||
var stripeID *string
|
||||
if tc.paid {
|
||||
id := "sub_" + orgID.String()
|
||||
stripeID = &id
|
||||
}
|
||||
if !tc.missingSubscription {
|
||||
exec(`INSERT INTO subscriptions (user_id, organization_id, plan_id, stripe_customer_id, stripe_subscription_id)
|
||||
VALUES ($1, $2, '00000000-0000-0000-0000-000000000001', '', $3)`, userID, orgID, stripeID)
|
||||
}
|
||||
if tc.managed {
|
||||
exec(`UPDATE subscriptions SET managed_at = now(), managed_plan_id = plan_id, managed_reason = 'operator grant' WHERE organization_id = $1`, orgID)
|
||||
}
|
||||
exec(`INSERT INTO credit_ledger (org_id, balance, purchased_balance) VALUES ($1, 25, 75)`, orgID)
|
||||
exec(readMigration("up"))
|
||||
var category string
|
||||
var managedID *uuid.UUID
|
||||
var completed, storedExpiry *time.Time
|
||||
var balance, purchased int
|
||||
if err := tx.QueryRow(ctx, `SELECT o.category, s.managed_plan_id, u.onboarding_completed_at, u.password_expires_at, c.balance, c.purchased_balance
|
||||
FROM organizations o JOIN users u ON u.id = o.owner_user_id
|
||||
JOIN subscriptions s ON s.organization_id = o.id JOIN credit_ledger c ON c.org_id = o.id WHERE o.id = $1`, orgID).
|
||||
Scan(&category, &managedID, &completed, &storedExpiry, &balance, &purchased); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
granted := managedID != nil && *managedID == models.TestPlanID
|
||||
wantBalance := 25
|
||||
if tc.grant {
|
||||
wantBalance += 100
|
||||
}
|
||||
if category != tc.category || granted != tc.grant || (completed != nil) != tc.onboarded || balance != wantBalance || purchased != 75 {
|
||||
t.Fatalf("category=%s granted=%v onboarded=%v balance=%d purchased=%d", category, granted, completed != nil, balance, purchased)
|
||||
}
|
||||
if tc.legacy && !tc.cli && storedExpiry == nil {
|
||||
t.Fatal("legacy reviewer did not receive a bounded password lifetime")
|
||||
}
|
||||
if tc.legacy && tc.expired && storedExpiry.After(time.Now()) {
|
||||
t.Fatal("upgrade renewed an expired legacy reviewer")
|
||||
}
|
||||
if tc.managed && (managedID == nil || *managedID == models.TestPlanID) {
|
||||
t.Fatal("migration overwrote an operator grant")
|
||||
}
|
||||
exec(readMigration("down"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,10 @@ import { usePermission } from "@/hooks/usePermission";
|
||||
import useAiMetered from "@/hooks/useAiMetered";
|
||||
import { DitherMeter } from "@/components/ui/dither";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/stores";
|
||||
|
||||
export function CreditsMeter() {
|
||||
const isTest = useAppStore((s) => s.currentOrganization?.category === "test");
|
||||
const canSee = usePermission("MANAGE_BILLING");
|
||||
const metered = useAiMetered();
|
||||
const credits = useCredits();
|
||||
@@ -72,8 +74,8 @@ export function CreditsMeter() {
|
||||
: 0;
|
||||
|
||||
const label = spendWindow
|
||||
? `${spendWindow.spent.toLocaleString()} of ${spendWindow.limit.toLocaleString()} credits used ${spendWindow.word}`
|
||||
: `${c.monthly_balance.toLocaleString()} of ${c.monthly_allowance.toLocaleString()} plan credits left`;
|
||||
? `${spendWindow.spent.toLocaleString()} of ${spendWindow.limit.toLocaleString()} ${isTest ? "test credits" : "credits"} used ${spendWindow.word}`
|
||||
: `${c.monthly_balance.toLocaleString()} of ${c.monthly_allowance.toLocaleString()} ${isTest ? "test" : "plan"} credits left`;
|
||||
const extraLabel =
|
||||
c.purchased_balance > 0 ? `, plus ${c.purchased_balance.toLocaleString()} extra` : "";
|
||||
|
||||
@@ -94,6 +96,7 @@ export function CreditsMeter() {
|
||||
)}
|
||||
>
|
||||
<Ring fraction={fraction} />
|
||||
{isTest && <span>Test credits</span>}
|
||||
{spendWindow ? (
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="flex items-baseline">
|
||||
@@ -126,7 +129,7 @@ export function CreditsMeter() {
|
||||
transition={{ duration: 0.12 }}
|
||||
className="absolute right-0 top-full mt-1.5 w-72 rounded-md border border-slate-200 bg-white shadow-[0_12px_32px_-8px_rgba(15,23,42,0.18)] z-50 overflow-hidden"
|
||||
>
|
||||
<MeterPanel credits={c} settings={settings.data} low={low} empty={empty} />
|
||||
<MeterPanel credits={c} settings={settings.data} low={low} empty={empty} isTest={isTest} />
|
||||
<Link
|
||||
to="/app/settings/billing/{-$tab}"
|
||||
params={{ tab: "ai-credits" }}
|
||||
@@ -149,11 +152,13 @@ function MeterPanel({
|
||||
settings,
|
||||
low,
|
||||
empty,
|
||||
isTest,
|
||||
}: {
|
||||
credits: CreditBalance;
|
||||
settings?: AISpendSettings;
|
||||
low: boolean;
|
||||
empty: boolean;
|
||||
isTest: boolean;
|
||||
}) {
|
||||
const usage = useCreditUsage();
|
||||
const planUsed = Math.max(0, credits.monthly_allowance - credits.monthly_balance);
|
||||
@@ -167,7 +172,7 @@ function MeterPanel({
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-400 font-medium">
|
||||
AI credits
|
||||
{isTest ? "Test credits" : "AI credits"}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-1.5">
|
||||
<AnimatedNumber
|
||||
@@ -200,7 +205,7 @@ function MeterPanel({
|
||||
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between text-[11.5px]">
|
||||
<span className="text-slate-600">Plan credits</span>
|
||||
<span className="text-slate-600">{isTest ? "Test credits" : "Plan credits"}</span>
|
||||
<span className="tabular-nums text-slate-900 font-medium">
|
||||
{credits.monthly_balance.toLocaleString()}
|
||||
<span className="text-slate-400 font-normal">
|
||||
|
||||
@@ -11,6 +11,7 @@ import useCloudPool from "@/hooks/useCloudPool";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import { PLAN_ACCENT_CLASSES, getPlan } from "@/lib/plans";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/stores";
|
||||
|
||||
export function PlanPill() {
|
||||
const access = useFeatureAccess();
|
||||
@@ -18,6 +19,18 @@ export function PlanPill() {
|
||||
// The Warmbly Cloud page is manage-settings gated; everyone else lands
|
||||
// on their profile rather than on a "no access" screen.
|
||||
const cloudTo = usePermission("MANAGE_SETTINGS") ? "/app/settings/warmbly-cloud" : "/app/settings/profile";
|
||||
const isTest = useAppStore((s) => s.currentOrganization?.category === "test");
|
||||
if (isTest) {
|
||||
return (
|
||||
<Badge
|
||||
to="/app/settings/profile"
|
||||
className="bg-sky-50 text-sky-700 border-sky-100"
|
||||
dot="bg-sky-500"
|
||||
label="Test"
|
||||
title="Dedicated tester workspace"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (access.loading) {
|
||||
return (
|
||||
<div className="h-6 w-16 rounded border border-slate-200 bg-slate-100 animate-pulse" />
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAppStore } from "@/stores";
|
||||
import useFeatureAccess from "@/hooks/useFeatureAccess";
|
||||
import { PlanPill } from "./PlanPill";
|
||||
import { CreditsMeter } from "./CreditsMeter";
|
||||
|
||||
const fixture = vi.hoisted(() => ({
|
||||
subscription: {
|
||||
data: { managed: true, status: "incomplete", plan: { id: "00000000-0000-0000-0000-0000000000e1", name: "Test" } },
|
||||
isPending: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
Link: ({ children, to, title, className }: PropsWithChildren<{ to: string; title?: string; className?: string }>) => (
|
||||
<a href={to} title={title} className={className}>{children}</a>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/api/hooks/app/subscription/useSubscription", () => ({ default: () => fixture.subscription }));
|
||||
vi.mock("@/lib/api/hooks/auth/useAuthConfig", () => ({ default: () => ({ data: { billing_enabled: true }, isLoading: false }) }));
|
||||
vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({}) }));
|
||||
vi.mock("@/hooks/usePermission", () => ({ usePermission: () => true }));
|
||||
vi.mock("@/hooks/useAiMetered", () => ({ default: () => true }));
|
||||
vi.mock("@/lib/api/hooks/app/subscription/useCredits", () => ({
|
||||
default: () => ({ isPending: false, data: { balance: 100, monthly_balance: 100, purchased_balance: 0, monthly_allowance: 100, packs: [] } }),
|
||||
}));
|
||||
vi.mock("@/lib/api/hooks/app/subscription/useCreditSettings", () => ({ useCreditSettings: () => ({ data: {} }) }));
|
||||
vi.mock("@/lib/api/hooks/app/subscription/useCreditUsage", () => ({ default: () => ({ isPending: false }) }));
|
||||
vi.mock("@/components/ui/AnimatedNumber", () => ({ default: ({ value }: { value: number }) => <span>{value}</span> }));
|
||||
vi.mock("@/components/ui/dither", () => ({ DitherMeter: () => <div /> }));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture.subscription.data = { managed: true, status: "incomplete", plan: { id: "00000000-0000-0000-0000-0000000000e1", name: "Test" } };
|
||||
useAppStore.setState({ currentOrganization: { id: "review", name: "Reviewer", category: "test", role: "owner" } });
|
||||
});
|
||||
afterEach(() => useAppStore.setState({ currentOrganization: null }));
|
||||
|
||||
describe("dedicated tester header", () => {
|
||||
it("shows Test instead of a customer plan, even after the grant expires", () => {
|
||||
fixture.subscription.data.managed = false;
|
||||
fixture.subscription.data.plan.name = "Free";
|
||||
render(<PlanPill />);
|
||||
expect(screen.getByRole("link", { name: "Test" })).toHaveAttribute("title", "Dedicated tester workspace");
|
||||
expect(screen.queryByText("Free")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels the bounded allowance as test credits in the header and popover", () => {
|
||||
render(<CreditsMeter />);
|
||||
const button = screen.getByRole("button", { name: "100 of 100 test credits left" });
|
||||
expect(button).toHaveTextContent("Test credits");
|
||||
fireEvent.click(button);
|
||||
expect(screen.queryByText("Plan credits")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Test credits").length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("leaves a customer workspace's plan and credit labels unchanged", () => {
|
||||
useAppStore.setState({ currentOrganization: { id: "customer", name: "Customer", category: "standard", role: "owner" } });
|
||||
fixture.subscription.data = { managed: false, status: "active", plan: { id: "customer-plan", name: "Starter" } };
|
||||
render(<><PlanPill /><CreditsMeter /></>);
|
||||
expect(screen.getByRole("link", { name: "Starter" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "100 of 100 plan credits left" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Test")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("unlocks paid surfaces from an active Test grant, never from the category alone", () => {
|
||||
const { result, rerender } = renderHook(() => useFeatureAccess());
|
||||
expect(result.current).toMatchObject({ paid: true, locked: false, hasTeam: true, hasBulkOps: true, hasAdvanced: true, hasWebhooks: true });
|
||||
fixture.subscription.data.managed = false;
|
||||
fixture.subscription.data.plan = { id: "free", name: "Free" };
|
||||
rerender();
|
||||
expect(result.current).toMatchObject({ paid: false, locked: true, hasTeam: false, hasBulkOps: false, hasAdvanced: false, hasWebhooks: false });
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,7 @@ import { useAppStore } from "@/stores";
|
||||
import { PERMISSION_BITS, hasPermission } from "@/lib/permissions";
|
||||
import {
|
||||
WARMUP_PLAN_ID,
|
||||
TEST_PLAN_ID,
|
||||
getPlan,
|
||||
isAtLeast,
|
||||
type PlanID,
|
||||
@@ -106,7 +107,10 @@ export default function useFeatureAccess(): FeatureAccess {
|
||||
};
|
||||
}
|
||||
|
||||
const planId = sub.data?.plan?.id === WARMUP_PLAN_ID
|
||||
const testGrant = sub.data?.managed === true && sub.data.plan?.id === TEST_PLAN_ID;
|
||||
const planId = testGrant
|
||||
? "business"
|
||||
: sub.data?.plan?.id === WARMUP_PLAN_ID
|
||||
? "warmup"
|
||||
: ((sub.data?.plan?.name ?? currentOrg?.plan ?? "free").toLowerCase()) as PlanID;
|
||||
const plan = getPlan(planId).id;
|
||||
|
||||
@@ -11,6 +11,7 @@ interface RawMembership {
|
||||
organization?: {
|
||||
id: string;
|
||||
name: string;
|
||||
category?: Organization["category"];
|
||||
slug?: string;
|
||||
avatar?: string;
|
||||
avatar_url?: string | null;
|
||||
@@ -36,6 +37,7 @@ export default async function getOrganizations(): Promise<Organization[]> {
|
||||
.map<Organization>((r) => ({
|
||||
id: r.organization!.id,
|
||||
name: r.organization!.name,
|
||||
category: r.organization!.category,
|
||||
avatar: r.organization!.avatar,
|
||||
avatar_url: r.organization!.avatar_url ?? null,
|
||||
plan: r.organization!.plan,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export default interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
category?: "standard" | "test"
|
||||
avatar?: string
|
||||
// Public URL of the workspace avatar, null/undefined when none is set.
|
||||
avatar_url?: string | null
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
export type PlanID = "free" | "warmup" | "starter" | "grow" | "business" | "enterprise";
|
||||
|
||||
export const TEST_PLAN_ID = "00000000-0000-0000-0000-0000000000e1";
|
||||
|
||||
export interface PlanDef {
|
||||
id: PlanID;
|
||||
label: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { StateCreator } from 'zustand'
|
||||
export interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
category?: "standard" | "test"
|
||||
avatar?: string
|
||||
avatar_url?: string | null
|
||||
plan?: string
|
||||
|
||||
Reference in New Issue
Block a user