@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.
- package/bin/tetra-init-tests.js +150 -0
- package/lib/checks/security/config-rls-alignment.js +1 -1
- package/lib/checks/security/rpc-security-mode.js +19 -0
- package/lib/templates/tests/01-auth.test.ts.tmpl +127 -0
- package/lib/templates/tests/07-security.test.ts.tmpl +93 -0
- package/lib/templates/tests/api-client.ts.tmpl +75 -0
- package/lib/templates/tests/test-users.ts.tmpl +86 -0
- package/package.json +3 -2
|
@@ -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+)?"
|
|
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.
|
|
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/",
|