feat(backend,web): self-service creator role upgrade via /settings
Some checks failed
Veza CI / Backend (Go) (push) Failing after 0s
Veza CI / Frontend (Web) (push) Failing after 0s
Veza CI / Rust (Stream Server) (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 0s
Veza CI / Notify on failure (push) Failing after 0s
Some checks failed
Veza CI / Backend (Go) (push) Failing after 0s
Veza CI / Frontend (Web) (push) Failing after 0s
Veza CI / Rust (Stream Server) (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 0s
Veza CI / Notify on failure (push) Failing after 0s
First item of the v1.0.6 backlog surfaced by the v1.0.5 smoke test: a
brand-new account could register, verify email, and log in — but
attempting to upload hit a 403 because `role='user'` doesn't pass the
`RequireContentCreatorRole` middleware. The only way to get past that
gate was an admin DB update.
This commit wires the self-service path decided in the v1.0.6
specification:
* One-way flip from `role='user'` to `role='creator'`, gated strictly
on `is_verified=true` (the verification-email flow we restored in
Fix 2 of the hardening sprint).
* No KYC, no cooldown, no admin validation. The conscious click
already requires ownership of the email address.
* Downgrade is out of scope — a creator who wants back to `user`
opens a support ticket. Avoids the "my uploads orphaned" edge case.
Backend
* Migration `977_users_promoted_to_creator_at.sql`: nullable
`TIMESTAMPTZ` column, partial index for non-null values. NULL
preserves the semantic for users who never self-promoted
(out-of-band admin assignments stay distinguishable from organic
creators for audit/analytics).
* `models.User`: new `PromotedToCreatorAt *time.Time` field.
* `handlers.UpgradeToCreator(db, auditService, logger)`:
- 401 if no `user_id` in context (belt-and-braces — middleware
should catch this first)
- 404 if the user row is missing
- 403 `EMAIL_NOT_VERIFIED` when `is_verified=false`
- 200 idempotent with `already_elevated=true` when the caller is
already creator / premium / moderator / admin / artist /
producer / label (same set accepted by
`RequireContentCreatorRole`)
- 200 with the new role + `promoted_to_creator_at` on the happy
path. The UPDATE is scoped `WHERE role='user'` so a concurrent
admin assignment can't be silently overwritten; the zero-rows
case reloads and returns `already_elevated=true`.
- audit logs a `user.upgrade_creator` action with IP, UA, and
the role transition metadata. Non-fatal on failure — the
upgrade itself already committed.
* Route: `POST /api/v1/users/me/upgrade-creator` under the existing
protected users group (RequireAuth + CSRF).
Frontend
* `AccountSettingsCreatorCard`: new card in the Account tab of
`/settings`. Completely hidden for users already on a creator-tier
role (no "you're already a creator" clutter). Unverified users see
a disabled-but-explanatory state with a "Resend verification"
CTA to `/verify-email/resend`. Verified users see the "Become an
artist" button, which POSTs to `/users/me/upgrade-creator` and
refetches the user on success.
* `upgradeToCreator()` service in `features/settings/services/`.
* Copy is deliberately explicit that the change is one-way.
Tests
* 6 Go unit tests covering: happy path (role + timestamp), unverified
refused, already-creator idempotent (timestamp preserved),
admin-assigned idempotent (no timestamp overwrite), user-not-found,
no-auth-context.
* 7 Vitest tests covering: verified button visible, unverified state
shown, card hidden for creator, card hidden for admin, success +
refetch, idempotent message, server error via toast.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f601441aa3
commit
c32278dc11
9 changed files with 615 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ import { AccountSettingsErrorBanner } from './AccountSettingsErrorBanner';
|
|||
import { AccountSettingsPasswordCard } from './AccountSettingsPasswordCard';
|
||||
import { AccountSettingsExportCard } from './AccountSettingsExportCard';
|
||||
import { AccountSettingsDeleteCard } from './AccountSettingsDeleteCard';
|
||||
import { AccountSettingsCreatorCard } from './AccountSettingsCreatorCard';
|
||||
import { TwoFactorSettings } from '../TwoFactorSettings';
|
||||
|
||||
export function AccountSettings() {
|
||||
|
|
@ -66,6 +67,8 @@ export function AccountSettings() {
|
|||
onSubmit={handleChangePassword}
|
||||
/>
|
||||
|
||||
<AccountSettingsCreatorCard />
|
||||
|
||||
<TwoFactorSettings />
|
||||
|
||||
<AccountSettingsExportCard onExport={handleExportData} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { AccountSettingsCreatorCard } from './AccountSettingsCreatorCard';
|
||||
|
||||
const mockRefetch = vi.fn();
|
||||
const mockUpgrade = vi.fn();
|
||||
const mockToastError = vi.fn();
|
||||
const mockToastSuccess = vi.fn();
|
||||
|
||||
vi.mock('@/features/auth/hooks/useUser', () => ({
|
||||
useUser: () => ({ data: (globalThis as any).__testUser, refetch: mockRefetch }),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/creatorUpgradeService', () => ({
|
||||
upgradeToCreator: () => mockUpgrade(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/toast', () => ({
|
||||
default: {
|
||||
error: (msg: string) => mockToastError(msg),
|
||||
success: (msg: string) => mockToastSuccess(msg),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
mockRefetch.mockReset();
|
||||
mockUpgrade.mockReset();
|
||||
mockToastError.mockReset();
|
||||
mockToastSuccess.mockReset();
|
||||
});
|
||||
|
||||
const setUser = (overrides: Record<string, unknown>) => {
|
||||
(globalThis as any).__testUser = {
|
||||
id: 'u1',
|
||||
username: 'test',
|
||||
email: 't@example.com',
|
||||
role: 'user',
|
||||
is_verified: true,
|
||||
is_active: true,
|
||||
is_admin: false,
|
||||
is_public: true,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
describe('AccountSettingsCreatorCard', () => {
|
||||
it('renders the upgrade button when user is verified and role=user', () => {
|
||||
setUser({});
|
||||
render(<AccountSettingsCreatorCard />);
|
||||
expect(screen.getByTestId('creator-upgrade-card')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('creator-upgrade-submit')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('shows the unverified state when email is not verified', () => {
|
||||
setUser({ is_verified: false });
|
||||
render(<AccountSettingsCreatorCard />);
|
||||
expect(screen.getByTestId('creator-upgrade-unverified')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('creator-upgrade-submit')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the entire card for creator-tier roles', () => {
|
||||
setUser({ role: 'creator' });
|
||||
const { container } = render(<AccountSettingsCreatorCard />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('hides the entire card for admins (already creator-tier)', () => {
|
||||
setUser({ role: 'admin' });
|
||||
const { container } = render(<AccountSettingsCreatorCard />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('calls upgradeToCreator and refetches the user on click', async () => {
|
||||
setUser({});
|
||||
mockUpgrade.mockResolvedValueOnce({
|
||||
role: 'creator',
|
||||
promoted_to_creator_at: '2026-04-16T14:00:00Z',
|
||||
already_elevated: false,
|
||||
});
|
||||
render(<AccountSettingsCreatorCard />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('creator-upgrade-submit'));
|
||||
|
||||
await waitFor(() => expect(mockUpgrade).toHaveBeenCalledTimes(1));
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(expect.stringContaining('Welcome'));
|
||||
expect(mockRefetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the idempotent success message when already elevated', async () => {
|
||||
setUser({});
|
||||
mockUpgrade.mockResolvedValueOnce({
|
||||
role: 'creator',
|
||||
promoted_to_creator_at: null,
|
||||
already_elevated: true,
|
||||
});
|
||||
render(<AccountSettingsCreatorCard />);
|
||||
fireEvent.click(screen.getByTestId('creator-upgrade-submit'));
|
||||
await waitFor(() => expect(mockToastSuccess).toHaveBeenCalled());
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(expect.stringContaining('already a creator'));
|
||||
});
|
||||
|
||||
it('surfaces server errors via toast.error', async () => {
|
||||
setUser({});
|
||||
mockUpgrade.mockRejectedValueOnce(new Error('network down'));
|
||||
render(<AccountSettingsCreatorCard />);
|
||||
fireEvent.click(screen.getByTestId('creator-upgrade-submit'));
|
||||
await waitFor(() => expect(mockToastError).toHaveBeenCalled());
|
||||
expect(mockToastError).toHaveBeenCalledWith('network down');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* AccountSettingsCreatorCard — v1.0.6.
|
||||
*
|
||||
* Self-service upgrade path from `role: 'user'` to `role: 'creator'`.
|
||||
* Hidden for users who are already creator-tier. Gated on `is_verified`:
|
||||
* unverified users see a "verify your email first" state with a resend
|
||||
* link instead of the upgrade button.
|
||||
*
|
||||
* Product decision: one-way. No downgrade UI — a creator who wants back
|
||||
* to `user` opens a support ticket. Keeps us out of the "my uploads
|
||||
* orphaned" edge case.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import toast from '@/utils/toast';
|
||||
import { Sparkles, BadgeCheck, Mail } from 'lucide-react';
|
||||
import { useUser } from '@/features/auth/hooks/useUser';
|
||||
import { upgradeToCreator } from '../../services/creatorUpgradeService';
|
||||
|
||||
const CREATOR_TIER_ROLES = new Set([
|
||||
'creator',
|
||||
'premium',
|
||||
'moderator',
|
||||
'admin',
|
||||
'artist',
|
||||
'producer',
|
||||
'label',
|
||||
]);
|
||||
|
||||
export function AccountSettingsCreatorCard() {
|
||||
const { data: user, refetch } = useUser();
|
||||
const [isUpgrading, setIsUpgrading] = useState(false);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Already creator-tier — hide the card. Nothing to offer here.
|
||||
if (CREATOR_TIER_ROLES.has(user.role)) return null;
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (!user.is_verified) {
|
||||
toast.error('Verify your email before upgrading to a creator account.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpgrading(true);
|
||||
try {
|
||||
const result = await upgradeToCreator();
|
||||
if (result.already_elevated) {
|
||||
toast.success('Your account was already a creator — nothing to change.');
|
||||
} else {
|
||||
toast.success('Welcome aboard. You can now upload tracks.');
|
||||
}
|
||||
// Pull the fresh user (new role) so other UI updates immediately.
|
||||
void refetch();
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to upgrade account.';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsUpgrading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="glass"
|
||||
className="p-6 space-y-4 border-primary/20"
|
||||
data-testid="creator-upgrade-card"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 rounded-lg bg-primary/10 p-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-base flex items-center gap-2">
|
||||
Become an artist
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Upgrade your account to a creator profile so you can upload tracks
|
||||
and publish releases. This is a one-way change; to revert, contact
|
||||
support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.is_verified ? (
|
||||
<div className="flex items-center justify-between gap-4 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm text-success-foreground">
|
||||
<BadgeCheck className="h-4 w-4 text-success" />
|
||||
Email verified — you're ready to upgrade.
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleUpgrade}
|
||||
disabled={isUpgrading}
|
||||
data-testid="creator-upgrade-submit"
|
||||
>
|
||||
{isUpgrading ? 'Upgrading…' : 'Become an artist'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 pt-2 rounded-lg border border-warning/40 bg-warning/5 p-3"
|
||||
data-testid="creator-upgrade-unverified"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4 text-warning" />
|
||||
<span>Verify your email before upgrading to an artist account.</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href="/verify-email/resend">Resend verification</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountSettingsCreatorCard;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Creator upgrade service — v1.0.6 self-service path for `role: 'user'` accounts.
|
||||
*
|
||||
* Hits POST /api/v1/users/me/upgrade-creator. The backend guards on
|
||||
* `is_verified=true` (403 EMAIL_NOT_VERIFIED otherwise) and idempotently
|
||||
* returns 200 when the caller is already creator-tier.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/services/api/client';
|
||||
|
||||
export interface UpgradeCreatorResponse {
|
||||
role: string;
|
||||
promoted_to_creator_at?: string | null;
|
||||
already_elevated: boolean;
|
||||
}
|
||||
|
||||
export async function upgradeToCreator(): Promise<UpgradeCreatorResponse> {
|
||||
const response = await apiClient.post<UpgradeCreatorResponse>(
|
||||
'/users/me/upgrade-creator',
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
|
@ -261,6 +261,14 @@ func (r *APIRouter) setupUserRoutes(router *gin.RouterGroup) {
|
|||
|
||||
// POST /me/privacy/opt-out: CCPA Do Not Sell (v0.803 SEC2-06)
|
||||
protected.POST("/me/privacy/opt-out", handlers.PrivacyOptOut(r.db.GormDB))
|
||||
|
||||
// POST /me/upgrade-creator: self-service creator role (v1.0.6)
|
||||
// Requires is_verified=true; one-way flip from 'user' to 'creator'.
|
||||
var upgradeAudit *services.AuditService
|
||||
if r.config != nil {
|
||||
upgradeAudit = r.config.AuditService
|
||||
}
|
||||
protected.POST("/me/upgrade-creator", handlers.UpgradeToCreator(r.db.GormDB, upgradeAudit, r.logger))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
176
veza-backend-api/internal/handlers/upgrade_creator_handler.go
Normal file
176
veza-backend-api/internal/handlers/upgrade_creator_handler.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
"veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
// UpgradeCreatorResponse is returned by POST /users/me/upgrade-creator.
|
||||
type UpgradeCreatorResponse struct {
|
||||
Role string `json:"role"`
|
||||
PromotedToCreatorAt *time.Time `json:"promoted_to_creator_at,omitempty"`
|
||||
AlreadyElevated bool `json:"already_elevated"`
|
||||
}
|
||||
|
||||
// creatorOrHigher lists roles that already have creator-tier privileges.
|
||||
// Self-promotion is a no-op for these (idempotent 200).
|
||||
func creatorOrHigher(role string) bool {
|
||||
switch role {
|
||||
case "creator", "premium", "moderator", "admin", "artist", "producer", "label":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpgradeToCreator returns the handler for POST /users/me/upgrade-creator.
|
||||
// Self-service path: a verified `role='user'` account flips to `role='creator'`
|
||||
// on one conscious click. One-way — downgrade requires a support ticket.
|
||||
//
|
||||
// Guards:
|
||||
// - 401 if no authenticated user (middleware should catch this first, belt+braces)
|
||||
// - 403 EMAIL_NOT_VERIFIED if `is_verified=false`
|
||||
// - 200 idempotent if the caller is already creator-tier or above
|
||||
// - 200 with the new role + promoted_to_creator_at if the flip happened
|
||||
func UpgradeToCreator(db *gorm.DB, auditService *services.AuditService, logger *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userIDRaw, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"error": gin.H{"code": "UNAUTHORIZED", "message": "User ID not found in context"},
|
||||
})
|
||||
return
|
||||
}
|
||||
userID, ok := userIDRaw.(uuid.UUID)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": gin.H{"code": "INVALID_USER_ID", "message": "Invalid user ID"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var user models.User
|
||||
if err := db.WithContext(ctx).First(&user, "id = ?", userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{"code": "USER_NOT_FOUND", "message": "User not found"},
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.Error("Failed to load user for creator upgrade",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to load user"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsVerified {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": gin.H{
|
||||
"code": "EMAIL_NOT_VERIFIED",
|
||||
"message": "Verify your email before upgrading to a creator account",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if creatorOrHigher(user.Role) {
|
||||
// Already creator-tier. Keep the existing role and promoted_to_creator_at
|
||||
// (may be nil for admin-assigned roles — that's fine, the timestamp only
|
||||
// records self-promotions).
|
||||
c.JSON(http.StatusOK, UpgradeCreatorResponse{
|
||||
Role: user.Role,
|
||||
PromotedToCreatorAt: user.PromotedToCreatorAt,
|
||||
AlreadyElevated: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]interface{}{
|
||||
"role": "creator",
|
||||
"promoted_to_creator_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
// Only flip if role is still 'user' — avoids racing with an admin who
|
||||
// may have assigned a different role between the load and the write.
|
||||
res := db.WithContext(ctx).
|
||||
Model(&models.User{}).
|
||||
Where("id = ? AND role = ?", userID, "user").
|
||||
Updates(updates)
|
||||
if res.Error != nil {
|
||||
logger.Error("Failed to upgrade user to creator",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(res.Error),
|
||||
)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to upgrade user"},
|
||||
})
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// Race: role changed between load and write. Re-load and treat as
|
||||
// "already elevated" (the race landed on a higher role, which is fine).
|
||||
if reloadErr := db.WithContext(ctx).First(&user, "id = ?", userID).Error; reloadErr == nil {
|
||||
c.JSON(http.StatusOK, UpgradeCreatorResponse{
|
||||
Role: user.Role,
|
||||
PromotedToCreatorAt: user.PromotedToCreatorAt,
|
||||
AlreadyElevated: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.Warn("Creator upgrade UPDATE matched zero rows and reload failed",
|
||||
zap.String("user_id", userID.String()),
|
||||
)
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": gin.H{"code": "UPGRADE_CONFLICT", "message": "Role changed concurrently, please retry"},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Log to audit trail (non-fatal on failure — upgrade already succeeded).
|
||||
if auditService != nil {
|
||||
if auditErr := auditService.LogAction(ctx, &services.AuditLogCreateRequest{
|
||||
UserID: &userID,
|
||||
Action: "user.upgrade_creator",
|
||||
Resource: "user",
|
||||
IPAddress: c.ClientIP(),
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
Metadata: map[string]interface{}{
|
||||
"from_role": "user",
|
||||
"to_role": "creator",
|
||||
"promoted_to_creator_at": now.Format(time.RFC3339),
|
||||
},
|
||||
}); auditErr != nil {
|
||||
logger.Warn("Failed to log creator upgrade in audit trail",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Error(auditErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("User upgraded to creator",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Time("promoted_at", now),
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, UpgradeCreatorResponse{
|
||||
Role: "creator",
|
||||
PromotedToCreatorAt: &now,
|
||||
AlreadyElevated: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"veza-backend-api/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap/zaptest"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupUpgradeCreatorTest(t *testing.T) (*gin.Engine, *gorm.DB, uuid.UUID) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db.Exec("PRAGMA foreign_keys = ON")
|
||||
require.NoError(t, db.AutoMigrate(&models.User{}))
|
||||
|
||||
userID := uuid.New()
|
||||
// Default seed: verified user with role=user. Individual tests can override.
|
||||
require.NoError(t, db.Create(&models.User{
|
||||
ID: userID,
|
||||
Username: "upgrader",
|
||||
Email: "upgrade@example.com",
|
||||
Role: "user",
|
||||
IsVerified: true,
|
||||
}).Error)
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
|
||||
router.POST("/users/me/upgrade-creator", UpgradeToCreator(db, nil, logger))
|
||||
|
||||
return router, db, userID
|
||||
}
|
||||
|
||||
func postUpgrade(t *testing.T, router *gin.Engine) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/users/me/upgrade-creator", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestUpgradeToCreator_VerifiedUserIsPromoted(t *testing.T) {
|
||||
router, db, userID := setupUpgradeCreatorTest(t)
|
||||
|
||||
w := postUpgrade(t, router)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp UpgradeCreatorResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "creator", resp.Role)
|
||||
assert.False(t, resp.AlreadyElevated)
|
||||
require.NotNil(t, resp.PromotedToCreatorAt)
|
||||
assert.WithinDuration(t, time.Now(), *resp.PromotedToCreatorAt, 5*time.Second)
|
||||
|
||||
var after models.User
|
||||
require.NoError(t, db.First(&after, "id = ?", userID).Error)
|
||||
assert.Equal(t, "creator", after.Role)
|
||||
require.NotNil(t, after.PromotedToCreatorAt)
|
||||
}
|
||||
|
||||
func TestUpgradeToCreator_UnverifiedIsRefused(t *testing.T) {
|
||||
router, db, userID := setupUpgradeCreatorTest(t)
|
||||
// Flip is_verified=false; default seed had it true.
|
||||
require.NoError(t, db.Model(&models.User{}).Where("id = ?", userID).Update("is_verified", false).Error)
|
||||
|
||||
w := postUpgrade(t, router)
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "EMAIL_NOT_VERIFIED")
|
||||
|
||||
var after models.User
|
||||
require.NoError(t, db.First(&after, "id = ?", userID).Error)
|
||||
assert.Equal(t, "user", after.Role, "role must not flip when email is not verified")
|
||||
assert.Nil(t, after.PromotedToCreatorAt)
|
||||
}
|
||||
|
||||
func TestUpgradeToCreator_AlreadyCreatorIsIdempotent(t *testing.T) {
|
||||
router, db, userID := setupUpgradeCreatorTest(t)
|
||||
earlier := time.Now().Add(-48 * time.Hour).UTC()
|
||||
require.NoError(t, db.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"role": "creator",
|
||||
"promoted_to_creator_at": earlier,
|
||||
}).Error)
|
||||
|
||||
w := postUpgrade(t, router)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp UpgradeCreatorResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "creator", resp.Role)
|
||||
assert.True(t, resp.AlreadyElevated)
|
||||
require.NotNil(t, resp.PromotedToCreatorAt)
|
||||
assert.WithinDuration(t, earlier, *resp.PromotedToCreatorAt, time.Second,
|
||||
"idempotent path must preserve the original promotion timestamp")
|
||||
}
|
||||
|
||||
func TestUpgradeToCreator_AdminIsIdempotentWithoutTimestamp(t *testing.T) {
|
||||
router, db, userID := setupUpgradeCreatorTest(t)
|
||||
// Admin was assigned out-of-band — no promoted_to_creator_at.
|
||||
require.NoError(t, db.Model(&models.User{}).Where("id = ?", userID).Update("role", "admin").Error)
|
||||
|
||||
w := postUpgrade(t, router)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var resp UpgradeCreatorResponse
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
assert.Equal(t, "admin", resp.Role, "admin must keep their role, not be downgraded to creator")
|
||||
assert.True(t, resp.AlreadyElevated)
|
||||
assert.Nil(t, resp.PromotedToCreatorAt, "admin assigned without the self-service flow has no timestamp")
|
||||
}
|
||||
|
||||
func TestUpgradeToCreator_UserNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.User{}))
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
router := gin.New()
|
||||
// Inject a user_id that doesn't exist in the DB
|
||||
router.Use(func(c *gin.Context) { c.Set("user_id", uuid.New()); c.Next() })
|
||||
router.POST("/users/me/upgrade-creator", UpgradeToCreator(db, nil, logger))
|
||||
|
||||
w := postUpgrade(t, router)
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "USER_NOT_FOUND")
|
||||
}
|
||||
|
||||
func TestUpgradeToCreator_NoAuthContext(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.User{}))
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
router := gin.New()
|
||||
// No middleware that sets user_id
|
||||
router.POST("/users/me/upgrade-creator", UpgradeToCreator(db, nil, logger))
|
||||
|
||||
w := postUpgrade(t, router)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "UNAUTHORIZED")
|
||||
}
|
||||
|
|
@ -35,6 +35,10 @@ type User struct {
|
|||
LastLoginAt *time.Time `json:"last_login_at" db:"last_login_at"`
|
||||
LoginCount int `gorm:"default:0;not null" json:"login_count" db:"login_count"`
|
||||
PasswordChangedAt *time.Time `json:"password_changed_at,omitempty" db:"password_changed_at"` // F016: Password expiration tracking
|
||||
// v1.0.6: set the first time a user self-promotes to `role='creator'`
|
||||
// via POST /api/v1/users/me/upgrade-creator. NULL for users who never
|
||||
// took that path (still 'user', or promoted by an admin out-of-band).
|
||||
PromotedToCreatorAt *time.Time `json:"promoted_to_creator_at,omitempty" db:"promoted_to_creator_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at" db:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
-- Migration 977: Track when a user was promoted to the 'creator' role (v1.0.6)
|
||||
-- Follow-up to the v1.0.5 audit: upload was gated on role=creator with no
|
||||
-- self-service path. v1.0.6 adds POST /api/v1/users/me/upgrade-creator; this
|
||||
-- column records when that one-way transition happened so audit + analytics
|
||||
-- can distinguish organic creators from legacy role assignments.
|
||||
--
|
||||
-- NULL = user has never been self-promoted (either still role='user' or
|
||||
-- was assigned a higher role by an admin without going through the flow).
|
||||
|
||||
ALTER TABLE public.users
|
||||
ADD COLUMN IF NOT EXISTS promoted_to_creator_at TIMESTAMPTZ NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_promoted_to_creator_at
|
||||
ON public.users(promoted_to_creator_at)
|
||||
WHERE promoted_to_creator_at IS NOT NULL;
|
||||
Loading…
Reference in a new issue