Files
sencho/backend/src/__tests__/labels-community-tier.test.ts
T
Anso 865d792874 feat(pricing): collapse to two tiers (#1309)
* feat(pricing): collapse to two tiers (Community + Admiral)

Collapse Sencho's pricing from three tiers (Community / Skipper / Admiral)
to two: a generous free Community tier and a single paid Admiral tier. The
Skipper tier is removed.

Now free in Community: auto-heal, auto-update, scheduled operations,
webhooks, notification routing, Fleet Actions and bulk operations, SSO
preset providers (Google / GitHub / Okta), unlimited users with admin and
viewer roles, and deploy safety (atomic deploys, auto-rollback, and
one-click rollback).

Admiral (paid) is focused on running and governing a fleet: blueprints,
Fleet Secrets, deploy enforcement, vulnerability report export, audit log,
host console, private registries, mesh networking, node cordon, managed
cloud backup, LDAP / Active Directory SSO, and the advanced RBAC roles
(deployer, node-admin, auditor) with per-resource scoped assignments.

Internally the license variant distinction is removed so tier is binary
(community / paid). License validation still verifies the Lemon Squeezy
store and product before granting paid status.

Docs and the contributor guide are updated to the two-tier model.

* docs(pricing): correct licensing page to two-tier pricing and tidy stale tier wording

The licensing docs page kept the old Admiral pricing plus a Founder
Lifetime column and an Enterprise paragraph after the two-tier collapse.
Update it to $12/month or $99/year, drop the lifetime and Enterprise
content, and link to the pricing page for current pricing.

Also fix stale "Skipper" wording in CLA.md, SUPPORT.md, one test title,
and three test comments. Historical CHANGELOG entries and the
retired-Skipper license-guard test are intentionally left as-is.

* docs: align licensing and SSO pages with the two-tier model

Correct the SSO overview so the Google, GitHub, and Okta presets read as
available on every tier, matching the provider table; only LDAP and Active
Directory require Sencho Admiral. Remove the lifetime-plan references from the
licensing, settings, and troubleshooting pages so they reflect subscription-only
Admiral pricing.

* fix(rbac): omit scoped permissions from /me on the Community tier

Scoped role assignments only take effect on the paid tier, but GET /api/permissions/me returned them unconditionally, so a downgraded instance with leftover assignments rendered per-resource affordances the API then rejected with 403. The endpoint now mirrors the permission middleware and includes scoped permissions only on the paid tier. Adds a regression test covering the downgrade case.

* docs: use custom-pricing wording on the contact page

The two-tier model has no Enterprise tier; reword the contact page's enterprise pricing/deals to custom pricing/deals so it does not imply a tier that no longer exists.
2026-06-04 17:45:53 -04:00

264 lines
10 KiB
TypeScript

/**
* Confirms Stack Labels CRUD + per-stack assignment + the per-label
* bulk-action endpoint are all reachable on the Community tier.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let viewerAuthHeader: string;
let nodeAdminAuthHeader: string;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
({ DatabaseService } = await import('../services/DatabaseService'));
DatabaseService.getInstance().addUser({ username: 'labels-viewer', password_hash: 'hash', role: 'viewer' });
DatabaseService.getInstance().addUser({ username: 'labels-node-admin', password_hash: 'hash', role: 'node-admin' });
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
const viewerToken = jwt.sign({ username: 'labels-viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' });
const nodeAdminToken = jwt.sign({ username: 'labels-node-admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
authHeader = `Bearer ${token}`;
viewerAuthHeader = `Bearer ${viewerToken}`;
nodeAdminAuthHeader = `Bearer ${nodeAdminToken}`;
});
afterAll(() => cleanupTestDb(tmpDir));
// Tests in this file accumulate labels in the shared DB; clear them between
// runs so later tests do not bump into MAX_LABELS_PER_NODE.
afterEach(() => {
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM stack_label_assignments').run();
db.prepare('DELETE FROM stack_labels').run();
});
function mockTier(tier: 'paid' | 'community') {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
}
describe('Stack Labels on Community tier', () => {
afterEach(() => vi.restoreAllMocks());
it('GET /api/labels returns 200 (empty array) on community', async () => {
mockTier('community');
const res = await request(app).get('/api/labels').set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('POST /api/labels creates a label on community', async () => {
mockTier('community');
const res = await request(app)
.post('/api/labels')
.set('Authorization', authHeader)
.send({ name: 'production', color: 'teal' });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ name: 'production', color: 'teal' });
expect(typeof res.body.id).toBe('number');
});
it('GET /api/labels/assignments returns 200 on community', async () => {
mockTier('community');
const res = await request(app).get('/api/labels/assignments').set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(typeof res.body).toBe('object');
});
it('PUT /api/labels/:id updates a label on community', async () => {
mockTier('community');
const created = await request(app)
.post('/api/labels')
.set('Authorization', authHeader)
.send({ name: 'staging', color: 'blue' });
expect(created.status).toBe(201);
const res = await request(app)
.put(`/api/labels/${created.body.id}`)
.set('Authorization', authHeader)
.send({ color: 'rose' });
expect(res.status).toBe(200);
expect(res.body.color).toBe('rose');
});
it('DELETE /api/labels/:id removes a label on community', async () => {
mockTier('community');
const created = await request(app)
.post('/api/labels')
.set('Authorization', authHeader)
.send({ name: 'temp', color: 'amber' });
expect(created.status).toBe(201);
const res = await request(app)
.delete(`/api/labels/${created.body.id}`)
.set('Authorization', authHeader);
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('allows node-admins to create labels through the stack edit permission', async () => {
mockTier('community');
const res = await request(app)
.post('/api/labels')
.set('Authorization', nodeAdminAuthHeader)
.send({ name: 'node-admin-label', color: 'green' });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ name: 'node-admin-label', color: 'green' });
});
it('PUT /api/stacks/:stackName/labels accepts an empty assignment on community', async () => {
mockTier('community');
const res = await request(app)
.put('/api/stacks/some-stack/labels')
.set('Authorization', authHeader)
.send({ labelIds: [] });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
it('rejects path traversal stack names before assigning labels', async () => {
mockTier('community');
const res = await request(app)
.put(`/api/stacks/${encodeURIComponent('../secret')}/labels`)
.set('Authorization', authHeader)
.send({ labelIds: [] });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Invalid stack name');
});
it('rejects labelIds arrays over the per-node cap', async () => {
mockTier('community');
const oversized = Array.from({ length: 51 }, (_, i) => i + 1);
const res = await request(app)
.put('/api/stacks/cap-stack/labels')
.set('Authorization', authHeader)
.send({ labelIds: oversized });
expect(res.status).toBe(400);
expect(res.body.error).toContain('may not exceed');
});
it('accepts labelIds at exactly the per-node cap', async () => {
mockTier('community');
// Seed 50 labels directly so the FK check on assignment passes without
// running 50 HTTP round trips per test. The route resolves nodeId via
// nodeContextMiddleware (default node when no x-node-id header), so the
// seeded rows must use the same nodeId the request will look up.
const { NodeRegistry } = await import('../services/NodeRegistry');
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
const db = DatabaseService.getInstance().getDb();
const insert = db.prepare('INSERT INTO stack_labels (node_id, name, color) VALUES (?, ?, ?)');
const ids: number[] = [];
for (let i = 0; i < 50; i++) {
const r = insert.run(nodeId, `cap-edge-${i}`, 'teal');
ids.push(r.lastInsertRowid as number);
}
const res = await request(app)
.put('/api/stacks/cap-edge-stack/labels')
.set('Authorization', authHeader)
.send({ labelIds: ids });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('Stack Labels RBAC', () => {
afterEach(() => vi.restoreAllMocks());
it('allows viewers to read labels but denies label creation', async () => {
mockTier('community');
const list = await request(app).get('/api/labels').set('Authorization', viewerAuthHeader);
expect(list.status).toBe(200);
const create = await request(app)
.post('/api/labels')
.set('Authorization', viewerAuthHeader)
.send({ name: 'viewer-create', color: 'teal' });
expect(create.status).toBe(403);
expect(create.body.code).toBe('PERMISSION_DENIED');
});
it('denies viewers label update, delete, and stack assignment', async () => {
mockTier('community');
const created = await request(app)
.post('/api/labels')
.set('Authorization', authHeader)
.send({ name: 'rbac-target', color: 'blue' });
expect(created.status).toBe(201);
const update = await request(app)
.put(`/api/labels/${created.body.id}`)
.set('Authorization', viewerAuthHeader)
.send({ color: 'rose' });
expect(update.status).toBe(403);
expect(update.body.code).toBe('PERMISSION_DENIED');
const assign = await request(app)
.put('/api/stacks/rbac-stack/labels')
.set('Authorization', viewerAuthHeader)
.send({ labelIds: [created.body.id] });
expect(assign.status).toBe(403);
expect(assign.body.code).toBe('PERMISSION_DENIED');
const remove = await request(app)
.delete(`/api/labels/${created.body.id}`)
.set('Authorization', viewerAuthHeader);
expect(remove.status).toBe(403);
expect(remove.body.code).toBe('PERMISSION_DENIED');
});
});
describe('Stack Labels Developer Mode logging', () => {
afterEach(() => vi.restoreAllMocks());
it('only emits label debug logs when Developer Mode is enabled', async () => {
mockTier('community');
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
const db = DatabaseService.getInstance();
db.updateGlobalSetting('developer_mode', '0');
const quiet = await request(app).get('/api/labels').set('Authorization', authHeader);
expect(quiet.status).toBe(200);
expect(debugSpy).not.toHaveBeenCalled();
db.updateGlobalSetting('developer_mode', '1');
const noisy = await request(app).get('/api/labels').set('Authorization', authHeader);
expect(noisy.status).toBe(200);
expect(debugSpy).toHaveBeenCalledWith(
'[Labels:debug] List labels: nodeId=',
expect.any(Number),
'count=',
expect.any(Number),
);
});
});
describe('Stack Labels bulk-action endpoint is available on Community', () => {
afterEach(() => vi.restoreAllMocks());
it('POST /api/labels/:id/action is reachable on community (no longer paid-gated)', async () => {
mockTier('community');
const created = await request(app)
.post('/api/labels')
.set('Authorization', authHeader)
.send({ name: 'bulk-action-free', color: 'teal' });
expect(created.status).toBe(201);
const res = await request(app)
.post(`/api/labels/${created.body.id}/action`)
.set('Authorization', authHeader)
.send({ action: 'restart' });
// No stacks are assigned to the label, so the bulk action succeeds with an
// empty result instead of the old 403 PAID_REQUIRED.
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
});