@soulbatical/tetra-dev-toolkit 1.20.0 → 1.20.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Tetra Init Tests — Scaffold golden standard E2E test structure.
5
+ *
6
+ * Creates:
7
+ * tests/e2e/helpers/api-client.ts (HTTP client with X-Test-Key bypass)
8
+ * tests/e2e/helpers/test-users.ts (Login + token caching)
9
+ * tests/e2e/01-auth.test.ts (Auth: login, session, refresh, logout)
10
+ * tests/e2e/07-security.test.ts (Auth walls + role access)
11
+ * vitest.config.e2e.ts (Vitest config for E2E)
12
+ *
13
+ * Usage:
14
+ * tetra-init-tests # Interactive
15
+ * tetra-init-tests --force # Overwrite existing files
16
+ */
17
+
18
+ import { program } from 'commander'
19
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs'
20
+ import { join, dirname } from 'path'
21
+ import { fileURLToPath } from 'url'
22
+
23
+ const __filename = fileURLToPath(import.meta.url)
24
+ const __dirname = dirname(__filename)
25
+ const TEMPLATES_DIR = join(__dirname, '..', 'lib', 'templates', 'tests')
26
+
27
+ function copyTemplate(templateName, destPath, force = false) {
28
+ if (existsSync(destPath) && !force) {
29
+ console.log(` ⏩ ${destPath} (exists, use --force to overwrite)`)
30
+ return false
31
+ }
32
+
33
+ const templatePath = join(TEMPLATES_DIR, templateName)
34
+ if (!existsSync(templatePath)) {
35
+ console.log(` ❌ Template not found: ${templateName}`)
36
+ return false
37
+ }
38
+
39
+ const content = readFileSync(templatePath, 'utf-8')
40
+ mkdirSync(dirname(destPath), { recursive: true })
41
+ writeFileSync(destPath, content)
42
+ console.log(` ✅ ${destPath}`)
43
+ return true
44
+ }
45
+
46
+ function writeFile(destPath, content, force = false) {
47
+ if (existsSync(destPath) && !force) {
48
+ console.log(` ⏩ ${destPath} (exists)`)
49
+ return false
50
+ }
51
+ mkdirSync(dirname(destPath), { recursive: true })
52
+ writeFileSync(destPath, content)
53
+ console.log(` ✅ ${destPath}`)
54
+ return true
55
+ }
56
+
57
+ program
58
+ .name('tetra-init-tests')
59
+ .description('Scaffold Tetra golden standard E2E test structure')
60
+ .option('--force', 'Overwrite existing files')
61
+ .option('--dir <path>', 'Test directory (default: tests/e2e)', 'tests/e2e')
62
+ .action((options) => {
63
+ const root = process.cwd()
64
+ const testDir = join(root, options.dir)
65
+ const helpersDir = join(testDir, 'helpers')
66
+ const force = !!options.force
67
+
68
+ console.log('\n Tetra Init Tests — Golden Standard E2E\n')
69
+
70
+ // 1. Helpers
71
+ console.log(' Helpers:')
72
+ copyTemplate('api-client.ts.tmpl', join(helpersDir, 'api-client.ts'), force)
73
+ copyTemplate('test-users.ts.tmpl', join(helpersDir, 'test-users.ts'), force)
74
+
75
+ // 2. Test files
76
+ console.log('\n Test files:')
77
+ copyTemplate('01-auth.test.ts.tmpl', join(testDir, '01-auth.test.ts'), force)
78
+ copyTemplate('07-security.test.ts.tmpl', join(testDir, '07-security.test.ts'), force)
79
+
80
+ // 3. Vitest config
81
+ console.log('\n Config:')
82
+ writeFile(join(root, 'vitest.config.e2e.ts'), `import { defineConfig } from 'vitest/config';
83
+
84
+ export default defineConfig({
85
+ test: {
86
+ include: ['${options.dir}/**/*.test.ts'],
87
+ globalSetup: './${options.dir}/global-setup.ts',
88
+ testTimeout: 30000,
89
+ hookTimeout: 60000,
90
+ sequence: { concurrent: false },
91
+ },
92
+ });
93
+ `, force)
94
+
95
+ // 4. Global setup stub
96
+ writeFile(join(testDir, 'global-setup.ts'), `/**
97
+ * Global setup — creates test users via API before all tests.
98
+ * CUSTOMIZE: implement signup + invite flow for your project.
99
+ *
100
+ * Required env vars (set these in your test runner or CI):
101
+ * E2E_BASE_URL — Backend URL (e.g. http://localhost:3026)
102
+ * RATE_LIMIT_BYPASS_KEY — X-Test-Key value (from .ralph/test-config.yml)
103
+ *
104
+ * This setup should:
105
+ * 1. Create an admin via signup
106
+ * 2. Create a shared resource (track/project/workspace)
107
+ * 3. Invite an elevated user (coach/manager)
108
+ * 4. Invite a basic user (client/member)
109
+ * 5. Store IDs/emails in process.env for test-users.ts
110
+ */
111
+
112
+ const BASE_URL = process.env.E2E_BASE_URL!;
113
+ const PASSWORD = 'TestPass1234';
114
+
115
+ export async function setup() {
116
+ // TODO: Implement for your project
117
+ // See CoachHub's global-setup.ts as reference
118
+ console.log('\\n ⚠️ global-setup.ts is a stub — implement for your project\\n');
119
+ }
120
+
121
+ export async function teardown() {
122
+ // Best-effort cleanup
123
+ }
124
+ `, force)
125
+
126
+ // Summary
127
+ console.log(`
128
+ Done! Next steps:
129
+
130
+ 1. Implement global-setup.ts (create test users via your API)
131
+ 2. Set E2E_BASE_URL and RATE_LIMIT_BYPASS_KEY in your env
132
+ 3. Customize 07-security.test.ts with your protected routes
133
+ 4. Add project-specific test files (02-*.test.ts, 03-*.test.ts, etc.)
134
+ 5. Run: npx vitest run --config vitest.config.e2e.ts
135
+
136
+ Golden standard pattern:
137
+ 01-auth — login, session, refresh, logout (universal)
138
+ 02-[resource] — main resource CRUD + permissions
139
+ 03-[nested] — nested resources (messages, notes, etc.)
140
+ 04-admin — admin-only features
141
+ 05-[feature] — feature-specific tests
142
+ 06-planner — scheduling (if using Tetra Planner)
143
+ 07-security — auth walls + role access (universal)
144
+ 08-permissions — permission matrix (generatable)
145
+ 09-isolation — cross-role data isolation
146
+ 10+ — additional features
147
+ `)
148
+ })
149
+
150
+ program.parse()
@@ -115,7 +115,7 @@ function parseMigrations(projectRoot) {
115
115
  const relFile = file.replace(projectRoot + '/', '')
116
116
 
117
117
  // Handle DROP POLICY — removes policy from earlier migration
118
- const dropPolicyMatches = content.matchAll(/DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?"?([^";\s]+)"?\s+ON\s+(?:public\.)?(\w+)/gi)
118
+ const dropPolicyMatches = content.matchAll(/DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?"([^"]+)"\s+ON\s+(?:public\.)?(\w+)/gi)
119
119
  for (const m of dropPolicyMatches) {
120
120
  const policyName = m[1]
121
121
  const table = m[2]
@@ -82,6 +82,9 @@ export async function run(config, projectRoot) {
82
82
  const userWhitelist = (config.supabase?.securityDefinerWhitelist || [])
83
83
  const whitelist = new Set([...BUILTIN_DEFINER_WHITELIST, ...userWhitelist])
84
84
 
85
+ // Sort files by name (timestamp order) so later migrations override earlier ones
86
+ sqlFiles.sort()
87
+
85
88
  // Track latest definition per function (migrations can override)
86
89
  const functions = new Map() // funcName → { securityMode, file, line, isDataQuery }
87
90
 
@@ -91,6 +94,22 @@ export async function run(config, projectRoot) {
91
94
 
92
95
  const relFile = file.replace(projectRoot + '/', '')
93
96
 
97
+ // Handle DROP FUNCTION — removes function from tracking
98
+ const dropFuncMatches = content.matchAll(/DROP\s+FUNCTION\s+(?:IF\s+EXISTS\s+)?(?:public\.)?(\w+)/gi)
99
+ for (const m of dropFuncMatches) {
100
+ functions.delete(m[1])
101
+ }
102
+
103
+ // Handle ALTER FUNCTION ... SECURITY INVOKER/DEFINER — overrides security mode
104
+ const alterFuncMatches = content.matchAll(/ALTER\s+FUNCTION\s+(?:public\.)?(\w+)(?:\s*\([^)]*\))?\s+SECURITY\s+(INVOKER|DEFINER)/gi)
105
+ for (const m of alterFuncMatches) {
106
+ const existing = functions.get(m[1])
107
+ if (existing) {
108
+ existing.securityMode = m[2].toUpperCase()
109
+ existing.file = relFile
110
+ }
111
+ }
112
+
94
113
  // Find all CREATE [OR REPLACE] FUNCTION statements
95
114
  const funcRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?(\w+)\s*\(/gi
96
115
  let match
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Auth E2E tests — signup, login, session, refresh, logout.
3
+ * Tetra golden standard — works for any Tetra project.
4
+ */
5
+ import { describe, it, expect, beforeAll } from 'vitest';
6
+ import { get, post } from './helpers/api-client';
7
+ import { getTestContext, loginTestUser, type TestContext } from './helpers/test-users';
8
+
9
+ let ctx: TestContext;
10
+
11
+ beforeAll(async () => {
12
+ ctx = await getTestContext();
13
+ });
14
+
15
+ describe('Auth: Users created with correct roles', () => {
16
+ it('admin exists with valid token', () => {
17
+ expect(ctx.admin.id).toBeTruthy();
18
+ expect(ctx.admin.token).toBeTruthy();
19
+ expect(ctx.admin.role).toBe('admin');
20
+ });
21
+
22
+ it('elevated user exists with valid token', () => {
23
+ expect(ctx.elevated.id).toBeTruthy();
24
+ expect(ctx.elevated.token).toBeTruthy();
25
+ });
26
+
27
+ it('basic user exists with valid token', () => {
28
+ expect(ctx.basic.id).toBeTruthy();
29
+ expect(ctx.basic.token).toBeTruthy();
30
+ });
31
+ });
32
+
33
+ describe('Auth: Login', () => {
34
+ it('logs in admin', async () => {
35
+ const tokens = await loginTestUser(ctx.admin.email);
36
+ expect(tokens.token).toBeTruthy();
37
+ expect(tokens.refreshToken).toBeTruthy();
38
+ });
39
+
40
+ it('rejects wrong password', async () => {
41
+ const res = await post('/api/public/auth/login', {
42
+ email: ctx.admin.email,
43
+ password: 'WrongPassword9999',
44
+ });
45
+ expect(res.status).toBe(401);
46
+ });
47
+
48
+ it('rejects nonexistent email', async () => {
49
+ const res = await post('/api/public/auth/login', {
50
+ email: 'nonexistent@test.dev',
51
+ password: 'TestPass1234',
52
+ });
53
+ expect(res.status).toBe(401);
54
+ });
55
+
56
+ it('rejects missing fields', async () => {
57
+ const res = await post('/api/public/auth/login', {});
58
+ expect(res.status).toBe(400);
59
+ });
60
+ });
61
+
62
+ describe('Auth: Session', () => {
63
+ it('validates a valid token', async () => {
64
+ const res = await get<any>('/api/public/auth/session', ctx.admin.token);
65
+ expect(res.status).toBe(200);
66
+ });
67
+
68
+ it('rejects invalid token', async () => {
69
+ const res = await get('/api/public/auth/session', 'invalid-token');
70
+ expect(res.status).toBe(401);
71
+ });
72
+
73
+ it('rejects missing token', async () => {
74
+ const res = await get('/api/public/auth/session');
75
+ expect(res.status).toBe(401);
76
+ });
77
+ });
78
+
79
+ describe('Auth: Refresh', () => {
80
+ it('refreshes a valid token', async () => {
81
+ const res = await post<any>('/api/public/auth/refresh', {
82
+ refresh_token: ctx.admin.refreshToken,
83
+ });
84
+ expect(res.status).toBe(200);
85
+ expect(res.data.data?.access_token).toBeTruthy();
86
+ ctx.admin.token = res.data.data.access_token;
87
+ ctx.admin.refreshToken = res.data.data.refresh_token;
88
+ });
89
+
90
+ it('rejects invalid refresh token', async () => {
91
+ const res = await post('/api/public/auth/refresh', {
92
+ refresh_token: 'invalid',
93
+ });
94
+ expect(res.status).toBe(401);
95
+ });
96
+ });
97
+
98
+ describe('Auth: Me', () => {
99
+ it('returns admin profile', async () => {
100
+ const res = await get<any>('/api/public/auth/me', ctx.admin.token);
101
+ expect(res.status).toBe(200);
102
+ expect(res.data.data?.role).toBe('admin');
103
+ });
104
+ });
105
+
106
+ describe('Auth: Logout', () => {
107
+ it('logs out successfully', async () => {
108
+ const tokens = await loginTestUser(ctx.elevated.email);
109
+ const res = await post<any>('/api/public/auth/logout', {}, tokens.token);
110
+ expect(res.status).toBe(200);
111
+ });
112
+ });
113
+
114
+ describe('Auth: Error format', () => {
115
+ it('401 returns RFC 7807', async () => {
116
+ const res = await get<any>('/api/tracks');
117
+ expect(res.status).toBe(401);
118
+ expect(res.data.type).toBeTruthy();
119
+ expect(res.data.status).toBe(401);
120
+ });
121
+
122
+ it('404 returns RFC 7807', async () => {
123
+ const res = await get<any>('/api/nonexistent-e2e', ctx.admin.token);
124
+ expect(res.status).toBe(404);
125
+ expect(res.data.type).toBeTruthy();
126
+ });
127
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Security E2E tests — auth walls + role-based access.
3
+ * Tetra golden standard — auto-reads protected routes from project config.
4
+ *
5
+ * CUSTOMIZE: Update protectedRoutes and role access tests for your project.
6
+ */
7
+ import { describe, it, expect, beforeAll } from 'vitest';
8
+ import { get, post, del, api } from './helpers/api-client';
9
+ import { getTestContext, type TestContext } from './helpers/test-users';
10
+
11
+ let ctx: TestContext;
12
+
13
+ beforeAll(async () => {
14
+ ctx = await getTestContext();
15
+ }, 60000);
16
+
17
+ // ── Auth walls: all protected routes require token ────────────
18
+ // CUSTOMIZE: add all your protected API routes here
19
+
20
+ const protectedRoutes = [
21
+ '/api/tracks',
22
+ '/api/users',
23
+ '/api/admin/dashboard',
24
+ '/api/permissions/my',
25
+ // Add project-specific routes below:
26
+ ];
27
+
28
+ describe('Security: Auth walls', () => {
29
+ for (const route of protectedRoutes) {
30
+ it(`${route} returns 401 without auth`, async () => {
31
+ const res = await get(route);
32
+ expect(res.status).toBe(401);
33
+ });
34
+ }
35
+ });
36
+
37
+ describe('Security: Invalid tokens', () => {
38
+ it('rejects malformed JWT', async () => {
39
+ const res = await get('/api/tracks', 'eyJhbGciOiJIUzI1NiJ9.fake.fake');
40
+ expect(res.status).toBe(401);
41
+ });
42
+
43
+ it('rejects empty Authorization header', async () => {
44
+ const res = await api('/api/tracks', { headers: { 'Authorization': '' } });
45
+ expect(res.status).toBe(401);
46
+ });
47
+ });
48
+
49
+ // ── Role-based access ─────────────────────────────────────────
50
+ // CUSTOMIZE: update for your project's roles and admin endpoints
51
+
52
+ describe('Security: Basic user cannot access admin endpoints', () => {
53
+ it('basic CANNOT access /api/admin/dashboard', async () => {
54
+ const res = await get('/api/admin/dashboard', ctx.basic.token);
55
+ expect(res.status).toBe(403);
56
+ });
57
+
58
+ it('basic CANNOT list users', async () => {
59
+ const res = await get('/api/users', ctx.basic.token);
60
+ expect(res.status).toBe(403);
61
+ });
62
+
63
+ it('basic CANNOT delete another user', async () => {
64
+ const res = await del(`/api/users/${ctx.admin.id}`, ctx.basic.token);
65
+ expect(res.status).toBe(403);
66
+ });
67
+ });
68
+
69
+ describe('Security: Elevated user cannot access admin-only endpoints', () => {
70
+ it('elevated CANNOT access /api/admin/dashboard', async () => {
71
+ const res = await get('/api/admin/dashboard', ctx.elevated.token);
72
+ expect(res.status).toBe(403);
73
+ });
74
+
75
+ it('elevated CANNOT delete another user', async () => {
76
+ const res = await del(`/api/users/${ctx.admin.id}`, ctx.elevated.token);
77
+ expect(res.status).toBe(403);
78
+ });
79
+ });
80
+
81
+ // ── Public routes: accessible without auth ────────────────────
82
+
83
+ describe('Security: Public routes', () => {
84
+ it('/api/health is public', async () => {
85
+ const res = await get('/api/health');
86
+ expect(res.status).toBe(200);
87
+ });
88
+
89
+ it('/api/version is public', async () => {
90
+ const res = await get('/api/version');
91
+ expect(res.status).toBe(200);
92
+ });
93
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Lightweight HTTP client for E2E tests.
3
+ * Tetra golden standard — generated by tetra init-tests.
4
+ *
5
+ * Features:
6
+ * - X-Test-Key header bypass for rate limiting
7
+ * - Simple get/post/put/del convenience methods
8
+ * - No dependencies — just fetch
9
+ */
10
+
11
+ const BASE_URL = () => process.env.E2E_BASE_URL!;
12
+ const TEST_KEY = () => process.env.RATE_LIMIT_BYPASS_KEY || '';
13
+
14
+ interface RequestOptions {
15
+ method?: string;
16
+ body?: Record<string, unknown>;
17
+ token?: string;
18
+ headers?: Record<string, string>;
19
+ }
20
+
21
+ interface ApiResponse<T = unknown> {
22
+ status: number;
23
+ data: T;
24
+ raw: Response;
25
+ }
26
+
27
+ export async function api<T = unknown>(
28
+ path: string,
29
+ options: RequestOptions = {}
30
+ ): Promise<ApiResponse<T>> {
31
+ const { method = 'GET', body, token, headers: extraHeaders } = options;
32
+
33
+ const headers: Record<string, string> = {
34
+ 'Content-Type': 'application/json',
35
+ ...extraHeaders,
36
+ };
37
+
38
+ if (token) {
39
+ headers['Authorization'] = `Bearer ${token}`;
40
+ }
41
+
42
+ // Bypass rate limiting in test runs
43
+ const testKey = TEST_KEY();
44
+ if (testKey) {
45
+ headers['X-Test-Key'] = testKey;
46
+ }
47
+
48
+ const url = `${BASE_URL()}${path}`;
49
+ const res = await fetch(url, {
50
+ method,
51
+ headers,
52
+ body: body ? JSON.stringify(body) : undefined,
53
+ });
54
+
55
+ let data: T;
56
+ try {
57
+ data = await res.json() as T;
58
+ } catch {
59
+ data = {} as T;
60
+ }
61
+
62
+ return { status: res.status, data, raw: res };
63
+ }
64
+
65
+ export const get = <T = unknown>(path: string, token?: string) =>
66
+ api<T>(path, { token });
67
+
68
+ export const post = <T = unknown>(path: string, body: Record<string, unknown>, token?: string) =>
69
+ api<T>(path, { method: 'POST', body, token });
70
+
71
+ export const put = <T = unknown>(path: string, body: Record<string, unknown>, token?: string) =>
72
+ api<T>(path, { method: 'PUT', body, token });
73
+
74
+ export const del = <T = unknown>(path: string, token?: string) =>
75
+ api<T>(path, { method: 'DELETE', token });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Test user context — reads from env vars set by global-setup.
3
+ * Tetra golden standard — generated by tetra init-tests.
4
+ *
5
+ * Users are in the SAME organization with real roles:
6
+ * - admin: created via signup (org owner)
7
+ * - coach/manager: invited with elevated role
8
+ * - client/member: invited with basic role
9
+ */
10
+
11
+ import { post } from './api-client';
12
+
13
+ export interface TestUser {
14
+ id: string;
15
+ email: string;
16
+ name: string;
17
+ role: string;
18
+ token: string;
19
+ refreshToken: string;
20
+ }
21
+
22
+ export interface TestContext {
23
+ admin: TestUser;
24
+ elevated: TestUser; // coach, manager, editor — depends on project
25
+ basic: TestUser; // client, member, viewer — depends on project
26
+ trackId: string; // shared resource ID (track, project, workspace, etc.)
27
+ }
28
+
29
+ let cachedContext: TestContext | null = null;
30
+
31
+ async function login(email: string): Promise<{ token: string; refreshToken: string }> {
32
+ const res = await post<any>('/api/public/auth/login', {
33
+ email,
34
+ password: process.env.E2E_PASSWORD || 'TestPass1234',
35
+ });
36
+
37
+ if (res.status !== 200 || !res.data?.data?.access_token) {
38
+ throw new Error(`Login failed for ${email}: ${res.status} ${JSON.stringify(res.data)}`);
39
+ }
40
+
41
+ return {
42
+ token: res.data.data.access_token,
43
+ refreshToken: res.data.data.refresh_token,
44
+ };
45
+ }
46
+
47
+ export async function getTestContext(): Promise<TestContext> {
48
+ if (cachedContext) return cachedContext;
49
+
50
+ const [adminTokens, elevatedTokens, basicTokens] = await Promise.all([
51
+ login(process.env.E2E_ADMIN_EMAIL!),
52
+ login(process.env.E2E_ELEVATED_EMAIL!),
53
+ login(process.env.E2E_BASIC_EMAIL!),
54
+ ]);
55
+
56
+ cachedContext = {
57
+ admin: {
58
+ id: process.env.E2E_ADMIN_ID!,
59
+ email: process.env.E2E_ADMIN_EMAIL!,
60
+ name: 'E2E Admin',
61
+ role: 'admin',
62
+ ...adminTokens,
63
+ },
64
+ elevated: {
65
+ id: process.env.E2E_ELEVATED_ID!,
66
+ email: process.env.E2E_ELEVATED_EMAIL!,
67
+ name: 'E2E Elevated',
68
+ role: process.env.E2E_ELEVATED_ROLE || 'coach',
69
+ ...elevatedTokens,
70
+ },
71
+ basic: {
72
+ id: process.env.E2E_BASIC_ID!,
73
+ email: process.env.E2E_BASIC_EMAIL!,
74
+ name: 'E2E Basic',
75
+ role: process.env.E2E_BASIC_ROLE || 'client',
76
+ ...basicTokens,
77
+ },
78
+ trackId: process.env.E2E_TRACK_ID!,
79
+ };
80
+
81
+ return cachedContext;
82
+ }
83
+
84
+ export async function loginTestUser(email: string): Promise<{ token: string; refreshToken: string }> {
85
+ return login(email);
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.20.0",
3
+ "version": "1.20.5",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },
@@ -34,7 +34,8 @@
34
34
  "tetra-db-push": "./bin/tetra-db-push.js",
35
35
  "tetra-check-peers": "./bin/tetra-check-peers.js",
36
36
  "tetra-security-gate": "./bin/tetra-security-gate.js",
37
- "tetra-smoke": "./bin/tetra-smoke.js"
37
+ "tetra-smoke": "./bin/tetra-smoke.js",
38
+ "tetra-init-tests": "./bin/tetra-init-tests.js"
38
39
  },
39
40
  "files": [
40
41
  "bin/",