start-vibing 4.3.3 → 4.4.0
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/package.json +2 -2
- package/template/.claude/agents/sd-audit.md +32 -0
- package/template/.claude/skills/e2e-audit/DESIGN.md +294 -0
- package/template/.claude/skills/e2e-audit/SKILL.md +660 -0
- package/template/.claude/skills/e2e-audit/e2e/fixtures/auth.setup.ts +70 -0
- package/template/.claude/skills/e2e-audit/e2e/fixtures/auth.ts +21 -0
- package/template/.claude/skills/e2e-audit/e2e/fixtures/base.ts +90 -0
- package/template/.claude/skills/e2e-audit/e2e/fixtures/storage/.gitkeep +0 -0
- package/template/.claude/skills/e2e-audit/e2e/fixtures/storage/admin.json +50 -0
- package/template/.claude/skills/e2e-audit/e2e/fixtures/storage/manager.json +50 -0
- package/template/.claude/skills/e2e-audit/e2e/fixtures/storage/member.json +50 -0
- package/template/.claude/skills/e2e-audit/e2e/fixtures/storage/owner.json +50 -0
- package/template/.claude/skills/e2e-audit/e2e/pages/dashboard-admin.page.ts +141 -0
- package/template/.claude/skills/e2e-audit/e2e/pages/dashboard-billing.page.ts +47 -0
- package/template/.claude/skills/e2e-audit/e2e/pages/dashboard-chat.page.ts +35 -0
- package/template/.claude/skills/e2e-audit/e2e/pages/dashboard-home.page.ts +134 -0
- package/template/.claude/skills/e2e-audit/e2e/pages/dashboard-integrations.page.ts +334 -0
- package/template/.claude/skills/e2e-audit/e2e/pages/dashboard-knowledge.page.ts +30 -0
- package/template/.claude/skills/e2e-audit/e2e/pages/dashboard-ontology.page.ts +71 -0
- package/template/.claude/skills/e2e-audit/e2e/pages/dashboard-profile.page.ts +38 -0
- package/template/.claude/skills/e2e-audit/e2e/pages/dashboard-teams.page.ts +123 -0
- package/template/.claude/skills/e2e-audit/e2e/pages/dashboard-transcripts.page.ts +109 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/auth/login.spec.ts +59 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/dashboard-admin.spec.ts +233 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/dashboard-billing.spec.ts +44 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/dashboard-chat.spec.ts +50 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/dashboard-home.spec.ts +243 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/dashboard-integrations.spec.ts +472 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/dashboard-knowledge.spec.ts +57 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/dashboard-ontology.spec.ts +72 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/dashboard-profile.spec.ts +48 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/dashboard-teams.spec.ts +247 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/dashboard-transcripts.spec.ts +122 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/security/headers.spec.ts +39 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/security/rbac.spec.ts +92 -0
- package/template/.claude/skills/e2e-audit/e2e/specs/security/xss.spec.ts +74 -0
- package/template/.claude/skills/e2e-audit/e2e/utils/console-collector.ts +89 -0
- package/template/.claude/skills/e2e-audit/e2e/utils/security-helpers.ts +114 -0
- package/template/.claude/skills/e2e-audit/e2e/utils/test-data.ts +64 -0
- package/template/.claude/skills/e2e-audit/runbook.md +115 -0
- package/template/.claude/skills/super-design/SKILL.md +42 -4
- package/template/.claude/skills/super-design/references/audit-methodology.md +63 -7
- package/template/.claude/skills/super-design/scripts/discover-surfaces.sh +197 -0
- package/template/.claude/skills/super-design/scripts/extract-project-rules.sh +240 -0
- package/template/.claude/skills/super-design/scripts/verify-audit.sh +34 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { test, expect } from '../fixtures/base'
|
|
2
|
+
import { DashboardTeamsPage } from '../pages/dashboard-teams.page'
|
|
3
|
+
import { createConsoleCollector } from '../utils/console-collector'
|
|
4
|
+
|
|
5
|
+
test.describe('Dashboard Teams @smoke', () => {
|
|
6
|
+
let teams: DashboardTeamsPage
|
|
7
|
+
|
|
8
|
+
test.beforeEach(async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
9
|
+
teams = new DashboardTeamsPage(authenticatedPage)
|
|
10
|
+
await teams.goto()
|
|
11
|
+
await teams.waitForLoad()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('page loads with correct heading and stats', async ({ authenticatedPage }) => {
|
|
15
|
+
await expect(authenticatedPage, 'Should navigate to teams page').toHaveURL(/dashboard\/teams/)
|
|
16
|
+
await expect(teams.heading).toBeVisible()
|
|
17
|
+
await expect(teams.subtitle).toBeVisible()
|
|
18
|
+
await expect(teams.statDepartments).toBeVisible()
|
|
19
|
+
await expect(teams.statTeams).toBeVisible()
|
|
20
|
+
await expect(teams.statMembers).toBeVisible()
|
|
21
|
+
await expect(teams.statMemberships).toBeVisible()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('displays 4 tabs', async () => {
|
|
25
|
+
await expect(teams.tabMembers).toBeVisible()
|
|
26
|
+
await expect(teams.tabDepartments).toBeVisible()
|
|
27
|
+
await expect(teams.tabTeams).toBeVisible()
|
|
28
|
+
await expect(teams.tabKnowledge).toBeVisible()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('Members tab is selected by default', async () => {
|
|
32
|
+
await expect(teams.tabMembers).toHaveAttribute('data-state', 'active')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test.describe('Dashboard Teams - Members Tab @smoke', () => {
|
|
37
|
+
let teams: DashboardTeamsPage
|
|
38
|
+
|
|
39
|
+
test.beforeEach(async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
40
|
+
teams = new DashboardTeamsPage(authenticatedPage)
|
|
41
|
+
await teams.goto()
|
|
42
|
+
await teams.waitForLoad()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('shows add member button and filters', async () => {
|
|
46
|
+
await expect(teams.addMemberButton).toBeVisible()
|
|
47
|
+
await expect(teams.memberSearchInput).toBeVisible()
|
|
48
|
+
await expect(teams.roleFilterCombobox).toBeVisible()
|
|
49
|
+
await expect(teams.statusFilterCombobox).toBeVisible()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('displays member cards', async () => {
|
|
53
|
+
const memberHeadings = await teams.page.getByRole('heading', { level: 4 }).all()
|
|
54
|
+
expect(memberHeadings.length).toBeGreaterThanOrEqual(1)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('role filter has 5 options', async ({ authenticatedPage }) => {
|
|
58
|
+
await teams.roleFilterCombobox.click()
|
|
59
|
+
const options = await authenticatedPage.getByRole('option').all()
|
|
60
|
+
expect(options.length).toBe(5)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('role filter correctly filters members', async ({ authenticatedPage }) => {
|
|
64
|
+
await teams.selectRoleFilter('Proprietário')
|
|
65
|
+
|
|
66
|
+
// After filtering by Owner, should show only owner members
|
|
67
|
+
await expect(authenticatedPage.getByRole('heading', { level: 4, name: 'João Lima' })).toBeVisible()
|
|
68
|
+
const memberHeadings = await authenticatedPage.getByRole('heading', { level: 4 }).all()
|
|
69
|
+
expect(memberHeadings.length).toBeGreaterThanOrEqual(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('search filters members by name', async ({ authenticatedPage }) => {
|
|
73
|
+
await teams.memberSearchInput.fill('João')
|
|
74
|
+
await authenticatedPage.waitForTimeout(500)
|
|
75
|
+
await expect(authenticatedPage.getByRole('heading', { level: 4, name: 'João Lima' })).toBeVisible()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('role filter Administrador shows only admins', async ({ authenticatedPage }) => {
|
|
79
|
+
await teams.selectRoleFilter('Administrador')
|
|
80
|
+
await authenticatedPage.waitForTimeout(500)
|
|
81
|
+
|
|
82
|
+
await expect(authenticatedPage.getByRole('heading', { level: 4, name: 'Patrick Miranda' })).toBeVisible()
|
|
83
|
+
await expect(authenticatedPage.getByRole('heading', { level: 4, name: 'Guilherme Moura' })).toBeVisible()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('status filter has 4 options', async ({ authenticatedPage }) => {
|
|
87
|
+
await teams.statusFilterCombobox.click()
|
|
88
|
+
const options = await authenticatedPage.getByRole('option').all()
|
|
89
|
+
expect(options.length).toBe(4)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('clicking member opens detail modal', async ({ authenticatedPage }) => {
|
|
93
|
+
await teams.clickMemberByName('João Lima')
|
|
94
|
+
const dialog = authenticatedPage.getByRole('dialog').first()
|
|
95
|
+
await expect(dialog).toBeVisible()
|
|
96
|
+
await expect(dialog.getByText('joao.lima@hakutaku.ai')).toBeVisible()
|
|
97
|
+
|
|
98
|
+
// Close modal
|
|
99
|
+
await dialog.getByRole('button', { name: 'Close' }).click()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('member detail modal has Detalhes and Equipes tabs', async ({ authenticatedPage }) => {
|
|
103
|
+
await teams.clickMemberByName('João Lima')
|
|
104
|
+
await expect(authenticatedPage.getByRole('tab', { name: 'Detalhes' })).toBeVisible()
|
|
105
|
+
await expect(authenticatedPage.getByRole('tab', { name: 'Equipes' })).toBeVisible()
|
|
106
|
+
|
|
107
|
+
// Switch to Equipes tab
|
|
108
|
+
await authenticatedPage.getByRole('tab', { name: 'Equipes' }).click()
|
|
109
|
+
await expect(authenticatedPage.getByText(/equipes do membro/i)).toBeVisible()
|
|
110
|
+
|
|
111
|
+
await authenticatedPage.getByRole('button', { name: 'Close' }).click()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('add member modal opens with required fields', async ({ authenticatedPage }) => {
|
|
115
|
+
await teams.addMemberButton.click()
|
|
116
|
+
const dialog = authenticatedPage.getByRole('dialog').first()
|
|
117
|
+
await expect(dialog).toBeVisible()
|
|
118
|
+
await expect(dialog.getByRole('textbox').first()).toBeVisible()
|
|
119
|
+
await expect(dialog.getByRole('combobox').first()).toBeVisible()
|
|
120
|
+
|
|
121
|
+
await authenticatedPage.getByRole('button', { name: 'Close' }).click()
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test.describe('Dashboard Teams - Departments Tab @smoke', () => {
|
|
126
|
+
let teams: DashboardTeamsPage
|
|
127
|
+
|
|
128
|
+
test.beforeEach(async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
129
|
+
teams = new DashboardTeamsPage(authenticatedPage)
|
|
130
|
+
await teams.goto()
|
|
131
|
+
await teams.waitForLoad()
|
|
132
|
+
await teams.switchToTab('departments')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('URL updates to ?tab=departments', async ({ authenticatedPage }) => {
|
|
136
|
+
await expect(authenticatedPage).toHaveURL(/tab=departments/)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('shows department table with columns', async ({ authenticatedPage }) => {
|
|
140
|
+
await expect(teams.departmentsTable).toBeVisible()
|
|
141
|
+
|
|
142
|
+
// Verify sortable column headers
|
|
143
|
+
await expect(authenticatedPage.getByRole('button', { name: /departamento/i })).toBeVisible()
|
|
144
|
+
await expect(authenticatedPage.getByRole('button', { name: /equipes/i })).toBeVisible()
|
|
145
|
+
await expect(authenticatedPage.getByRole('button', { name: /membros/i })).toBeVisible()
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('department row menu has correct options', async ({ authenticatedPage }) => {
|
|
149
|
+
await teams.departmentsTable.waitFor({ timeout: 10000 })
|
|
150
|
+
await authenticatedPage.getByRole('button', { name: /abrir menu|open menu/i }).first().waitFor({ timeout: 5000 })
|
|
151
|
+
await teams.openRowMenu(0)
|
|
152
|
+
await expect(authenticatedPage.getByRole('menuitem', { name: /ver detalhes|view details/i })).toBeVisible()
|
|
153
|
+
await expect(authenticatedPage.getByRole('menuitem', { name: /editar departamento|edit department/i })).toBeVisible()
|
|
154
|
+
await expect(authenticatedPage.getByRole('menuitem', { name: /excluir departamento|delete department/i })).toBeVisible()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('department detail modal opens from menu', async ({ authenticatedPage }) => {
|
|
158
|
+
await teams.departmentsTable.waitFor({ timeout: 10000 })
|
|
159
|
+
await authenticatedPage.getByRole('button', { name: /abrir menu|open menu/i }).first().waitFor({ timeout: 5000 })
|
|
160
|
+
await teams.openRowMenu(0)
|
|
161
|
+
await authenticatedPage.getByRole('menuitem', { name: /ver detalhes|view details/i }).click()
|
|
162
|
+
await expect(authenticatedPage.getByRole('dialog').first()).toBeVisible()
|
|
163
|
+
|
|
164
|
+
await authenticatedPage.getByRole('button', { name: 'Close' }).click()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test.describe('Dashboard Teams - Teams Tab @smoke', () => {
|
|
169
|
+
let teams: DashboardTeamsPage
|
|
170
|
+
|
|
171
|
+
test.beforeEach(async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
172
|
+
teams = new DashboardTeamsPage(authenticatedPage)
|
|
173
|
+
await teams.goto()
|
|
174
|
+
await teams.waitForLoad()
|
|
175
|
+
await teams.switchToTab('teams')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('URL updates to ?tab=teams', async ({ authenticatedPage }) => {
|
|
179
|
+
await expect(authenticatedPage).toHaveURL(/tab=teams/)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('shows teams table with data', async ({ authenticatedPage }) => {
|
|
183
|
+
await expect(teams.teamsTable).toBeVisible()
|
|
184
|
+
|
|
185
|
+
const rows = await authenticatedPage.getByRole('row').all()
|
|
186
|
+
// At least 1 header row + 1 data row
|
|
187
|
+
expect(rows.length).toBeGreaterThan(1)
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test.describe('Dashboard Teams - Knowledge Tab @smoke', () => {
|
|
192
|
+
let teams: DashboardTeamsPage
|
|
193
|
+
|
|
194
|
+
test.beforeEach(async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
195
|
+
teams = new DashboardTeamsPage(authenticatedPage)
|
|
196
|
+
await teams.goto()
|
|
197
|
+
await teams.waitForLoad()
|
|
198
|
+
await teams.switchToTab('knowledge')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('URL updates to ?tab=knowledge', async ({ authenticatedPage }) => {
|
|
202
|
+
await expect(authenticatedPage).toHaveURL(/tab=knowledge/)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('shows knowledge permissions with empty state', async ({ authenticatedPage }) => {
|
|
206
|
+
await expect(teams.knowledgeTitle).toBeVisible()
|
|
207
|
+
await expect(authenticatedPage.getByText(/nenhuma permissão atribuída|no permissions assigned/i)).toBeVisible()
|
|
208
|
+
await expect(teams.addPermissionsButton).toBeVisible()
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test.describe('Dashboard Teams - Security @security', () => {
|
|
213
|
+
test('no sensitive data in console', async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
214
|
+
const collector = createConsoleCollector(authenticatedPage)
|
|
215
|
+
const teams = new DashboardTeamsPage(authenticatedPage)
|
|
216
|
+
await teams.goto()
|
|
217
|
+
await teams.waitForLoad()
|
|
218
|
+
collector.assertNoSensitiveLeaks()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('XSS payloads in member names are escaped', async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
222
|
+
const teams = new DashboardTeamsPage(authenticatedPage)
|
|
223
|
+
await teams.goto()
|
|
224
|
+
await teams.waitForLoad()
|
|
225
|
+
|
|
226
|
+
// Verify XSS payload is rendered as text, not executed
|
|
227
|
+
const xssHeading = authenticatedPage.getByRole('heading', { level: 4, name: '<script>alert(1)</script>' })
|
|
228
|
+
await expect(xssHeading.first()).toBeVisible()
|
|
229
|
+
|
|
230
|
+
// Verify no alert dialog was triggered
|
|
231
|
+
let dialogTriggered = false
|
|
232
|
+
authenticatedPage.on('dialog', () => {
|
|
233
|
+
dialogTriggered = true
|
|
234
|
+
})
|
|
235
|
+
expect(dialogTriggered).toBe(false)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('XSS payloads in team names are escaped', async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
239
|
+
const teams = new DashboardTeamsPage(authenticatedPage)
|
|
240
|
+
await teams.goto()
|
|
241
|
+
await teams.waitForLoad()
|
|
242
|
+
await teams.switchToTab('teams')
|
|
243
|
+
|
|
244
|
+
// Verify XSS team name is rendered as text
|
|
245
|
+
await expect(authenticatedPage.getByText('<img src=x onerror=alert(1)>')).toBeVisible()
|
|
246
|
+
})
|
|
247
|
+
})
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { test, expect } from '../fixtures/base'
|
|
2
|
+
import { DashboardTranscriptsPage } from '../pages/dashboard-transcripts.page'
|
|
3
|
+
import { createConsoleCollector } from '../utils/console-collector'
|
|
4
|
+
|
|
5
|
+
test.describe('Dashboard Transcripts @smoke', () => {
|
|
6
|
+
let transcripts: DashboardTranscriptsPage
|
|
7
|
+
|
|
8
|
+
test.beforeEach(async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
9
|
+
transcripts = new DashboardTranscriptsPage(authenticatedPage)
|
|
10
|
+
await transcripts.goto()
|
|
11
|
+
await transcripts.waitForLoad()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('page loads with heading, description, and download links', async ({ authenticatedPage }) => {
|
|
15
|
+
await expect(authenticatedPage, 'Should navigate to transcripts page').toHaveURL(/dashboard\/transcripts/)
|
|
16
|
+
await expect(transcripts.heading, 'Transcripts heading should be visible').toBeVisible()
|
|
17
|
+
await expect(transcripts.description).toBeVisible()
|
|
18
|
+
await expect(transcripts.windowsDownloadLink).toBeVisible()
|
|
19
|
+
await expect(transcripts.macDownloadLink).toBeVisible()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('shows stats cards', async () => {
|
|
23
|
+
await expect(transcripts.totalTranscriptsStat).toBeVisible()
|
|
24
|
+
await expect(transcripts.totalDurationStat).toBeVisible()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('shows three tabs with meetings selected by default', async () => {
|
|
28
|
+
await expect(transcripts.meetingsTab).toBeVisible()
|
|
29
|
+
await expect(transcripts.documentsTab).toBeVisible()
|
|
30
|
+
await expect(transcripts.conflictsTab).toBeVisible()
|
|
31
|
+
await expect(transcripts.meetingsTab).toHaveAttribute('aria-selected', 'true')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('tabs switch via URL', async ({ authenticatedPage }) => {
|
|
35
|
+
await transcripts.switchTab('documents')
|
|
36
|
+
await expect(authenticatedPage).toHaveURL(/tab=documents/)
|
|
37
|
+
|
|
38
|
+
await transcripts.switchTab('conflicts')
|
|
39
|
+
await expect(authenticatedPage).toHaveURL(/tab=conflicts/)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test.describe('Dashboard Transcripts - Meetings Tab', () => {
|
|
44
|
+
let transcripts: DashboardTranscriptsPage
|
|
45
|
+
|
|
46
|
+
test.beforeEach(async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
47
|
+
transcripts = new DashboardTranscriptsPage(authenticatedPage)
|
|
48
|
+
await transcripts.goto()
|
|
49
|
+
await transcripts.waitForLoad()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('displays search and status filter', async () => {
|
|
53
|
+
await expect(transcripts.meetingsSearchInput).toBeVisible()
|
|
54
|
+
await expect(transcripts.meetingsStatusFilter).toBeVisible()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('meetings table has rows with data', async () => {
|
|
58
|
+
await transcripts.meetingsTable.waitFor()
|
|
59
|
+
const rowCount = await transcripts.getMeetingRowCount()
|
|
60
|
+
expect(rowCount).toBeGreaterThan(0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('pagination shows correct info', async () => {
|
|
64
|
+
await transcripts.meetingsTable.waitFor()
|
|
65
|
+
await expect(transcripts.paginationText).toBeVisible()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('search filters transcripts', async () => {
|
|
69
|
+
await transcripts.meetingsSearchInput.fill('Google Meet')
|
|
70
|
+
await expect(transcripts.paginationText).toContainText('de 7')
|
|
71
|
+
const rowCount = await transcripts.getMeetingRowCount()
|
|
72
|
+
expect(rowCount).toBe(7)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('clicking a transcript row navigates to detail page', async ({ authenticatedPage }) => {
|
|
76
|
+
await transcripts.meetingsTable.waitFor()
|
|
77
|
+
await transcripts.clickTranscriptRow('Meeting')
|
|
78
|
+
await expect(authenticatedPage).toHaveURL(/dashboard\/transcripts\/[0-9a-f-]+/)
|
|
79
|
+
await expect(transcripts.detailHeading).toBeVisible()
|
|
80
|
+
await expect(transcripts.detailStatusBadge).toBeVisible()
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test.describe('Dashboard Transcripts - Documents Tab', () => {
|
|
85
|
+
test('shows empty state', async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
86
|
+
const transcripts = new DashboardTranscriptsPage(authenticatedPage)
|
|
87
|
+
await transcripts.goto('documents')
|
|
88
|
+
await transcripts.waitForLoad()
|
|
89
|
+
await expect(transcripts.documentsEmptyHeading, 'Documents tab should show empty state').toBeVisible()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test.describe('Dashboard Transcripts - Conflicts Tab', () => {
|
|
94
|
+
test('shows empty state', async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
95
|
+
const transcripts = new DashboardTranscriptsPage(authenticatedPage)
|
|
96
|
+
await transcripts.goto('conflicts')
|
|
97
|
+
await transcripts.waitForLoad()
|
|
98
|
+
await expect(transcripts.conflictsHeading, 'Conflicts tab heading should be visible').toBeVisible()
|
|
99
|
+
await expect(transcripts.conflictsEmptyHeading).toBeVisible()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test.describe('Dashboard Transcripts - Security @security', () => {
|
|
104
|
+
test('no sensitive data in console', async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
105
|
+
const collector = createConsoleCollector(authenticatedPage)
|
|
106
|
+
const transcripts = new DashboardTranscriptsPage(authenticatedPage)
|
|
107
|
+
await transcripts.goto()
|
|
108
|
+
await transcripts.waitForLoad()
|
|
109
|
+
collector.assertNoSensitiveLeaks()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('download links point to expected S3 domain', async ({ authenticatedPage, apiErrors: _apiErrors }) => {
|
|
113
|
+
const transcripts = new DashboardTranscriptsPage(authenticatedPage)
|
|
114
|
+
await transcripts.goto()
|
|
115
|
+
await transcripts.waitForLoad()
|
|
116
|
+
|
|
117
|
+
const winHref = await transcripts.windowsDownloadLink.getAttribute('href')
|
|
118
|
+
const macHref = await transcripts.macDownloadLink.getAttribute('href')
|
|
119
|
+
expect(winHref).toContain('hakutaku-releases.s3.amazonaws.com')
|
|
120
|
+
expect(macHref).toContain('hakutaku-releases.s3.amazonaws.com')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { test, expect } from '@playwright/test'
|
|
2
|
+
import { REQUIRED_SECURITY_HEADERS } from '../../utils/security-helpers'
|
|
3
|
+
import { ROUTES } from '../../utils/test-data'
|
|
4
|
+
|
|
5
|
+
test.describe('Security Headers @security', () => {
|
|
6
|
+
const pagesToCheck = [
|
|
7
|
+
ROUTES.root,
|
|
8
|
+
ROUTES.home,
|
|
9
|
+
ROUTES.knowledge,
|
|
10
|
+
ROUTES.chat,
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
for (const route of pagesToCheck) {
|
|
14
|
+
test(`${route} has required security headers`, async ({ page }) => {
|
|
15
|
+
const response = await page.goto(route)
|
|
16
|
+
expect(response).not.toBeNull()
|
|
17
|
+
|
|
18
|
+
if (response) {
|
|
19
|
+
const headers = response.headers()
|
|
20
|
+
|
|
21
|
+
for (const header of REQUIRED_SECURITY_HEADERS) {
|
|
22
|
+
expect(
|
|
23
|
+
headers[header],
|
|
24
|
+
`Missing header "${header}" on ${route}`,
|
|
25
|
+
).toBeDefined()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('API health endpoint has security headers', async ({ page }) => {
|
|
32
|
+
const response = await page.goto('/api/health')
|
|
33
|
+
expect(response).not.toBeNull()
|
|
34
|
+
|
|
35
|
+
if (response) {
|
|
36
|
+
expect(response.status()).toBe(200)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { test as baseTest, expect } from '@playwright/test'
|
|
2
|
+
import { test } from '../../fixtures/base'
|
|
3
|
+
import { assertAccessDenied } from '../../utils/security-helpers'
|
|
4
|
+
import { RBAC_ROUTES } from '../../utils/test-data'
|
|
5
|
+
|
|
6
|
+
test.describe('RBAC - Admin Routes @security', () => {
|
|
7
|
+
test.describe('as MEMBER', () => {
|
|
8
|
+
test.use({ role: 'member' })
|
|
9
|
+
|
|
10
|
+
for (const route of RBAC_ROUTES.admin) {
|
|
11
|
+
test(`cannot access ${route}`, async ({ authenticatedPage }) => {
|
|
12
|
+
await assertAccessDenied(authenticatedPage, route)
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test.describe('as MANAGER', () => {
|
|
18
|
+
test.use({ role: 'manager' })
|
|
19
|
+
|
|
20
|
+
for (const route of RBAC_ROUTES.admin) {
|
|
21
|
+
test(`cannot access ${route}`, async ({ authenticatedPage }) => {
|
|
22
|
+
await assertAccessDenied(authenticatedPage, route)
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test.describe('as ADMIN', () => {
|
|
28
|
+
test.use({ role: 'admin' })
|
|
29
|
+
|
|
30
|
+
for (const route of RBAC_ROUTES.admin) {
|
|
31
|
+
test(`can access ${route}`, async ({ authenticatedPage }) => {
|
|
32
|
+
await authenticatedPage.goto(route)
|
|
33
|
+
expect(authenticatedPage.url()).not.toContain('/unauthorized')
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test.describe('RBAC - Owner Routes @security', () => {
|
|
40
|
+
test.describe('as MEMBER', () => {
|
|
41
|
+
test.use({ role: 'member' })
|
|
42
|
+
|
|
43
|
+
for (const route of RBAC_ROUTES.owner) {
|
|
44
|
+
test(`cannot access ${route}`, async ({ authenticatedPage }) => {
|
|
45
|
+
await assertAccessDenied(authenticatedPage, route)
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test.describe('as ADMIN', () => {
|
|
51
|
+
test.use({ role: 'admin' })
|
|
52
|
+
|
|
53
|
+
for (const route of RBAC_ROUTES.owner) {
|
|
54
|
+
test(`cannot access ${route}`, async ({ authenticatedPage }) => {
|
|
55
|
+
await assertAccessDenied(authenticatedPage, route)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test.describe('as OWNER', () => {
|
|
61
|
+
test.use({ role: 'owner' })
|
|
62
|
+
|
|
63
|
+
for (const route of RBAC_ROUTES.owner) {
|
|
64
|
+
test(`can access ${route}`, async ({ authenticatedPage }) => {
|
|
65
|
+
await authenticatedPage.goto(route)
|
|
66
|
+
expect(authenticatedPage.url()).not.toContain('/unauthorized')
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
baseTest.describe('RBAC - Unauthenticated @security', () => {
|
|
73
|
+
const allProtectedRoutes = [
|
|
74
|
+
...RBAC_ROUTES.member,
|
|
75
|
+
...RBAC_ROUTES.manager,
|
|
76
|
+
...RBAC_ROUTES.admin,
|
|
77
|
+
...RBAC_ROUTES.owner,
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
for (const route of allProtectedRoutes) {
|
|
81
|
+
baseTest(`redirects unauthenticated from ${route}`, async ({ page }) => {
|
|
82
|
+
await page.goto(route)
|
|
83
|
+
const url = page.url()
|
|
84
|
+
const redirected =
|
|
85
|
+
url.includes('/auth') ||
|
|
86
|
+
url.includes('/unauthorized') ||
|
|
87
|
+
url === 'http://localhost:3000/' ||
|
|
88
|
+
url === 'https://dev.hakutaku.ai/'
|
|
89
|
+
expect(redirected).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { test, expect } from '../../fixtures/base'
|
|
2
|
+
import { XSS_PAYLOADS } from '../../utils/security-helpers'
|
|
3
|
+
import { ROUTES } from '../../utils/test-data'
|
|
4
|
+
|
|
5
|
+
test.describe('XSS Prevention @security', () => {
|
|
6
|
+
test('chat input sanitizes XSS payloads', async ({ authenticatedPage }) => {
|
|
7
|
+
await authenticatedPage.goto(ROUTES.chat)
|
|
8
|
+
await authenticatedPage.waitForLoadState('networkidle')
|
|
9
|
+
|
|
10
|
+
const input = authenticatedPage.getByRole('textbox').first()
|
|
11
|
+
const inputVisible = await input.isVisible().catch(() => false)
|
|
12
|
+
if (!inputVisible) return
|
|
13
|
+
|
|
14
|
+
const dialogFired: string[] = []
|
|
15
|
+
authenticatedPage.on('dialog', async (dialog) => {
|
|
16
|
+
dialogFired.push(dialog.message())
|
|
17
|
+
await dialog.dismiss()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
for (const payload of XSS_PAYLOADS) {
|
|
21
|
+
await input.clear()
|
|
22
|
+
await input.fill(payload)
|
|
23
|
+
await input.press('Tab')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
expect(dialogFired).toHaveLength(0)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('knowledge search sanitizes XSS payloads', async ({ authenticatedPage }) => {
|
|
30
|
+
await authenticatedPage.goto(ROUTES.knowledge)
|
|
31
|
+
await authenticatedPage.waitForLoadState('networkidle')
|
|
32
|
+
|
|
33
|
+
const search = authenticatedPage.getByPlaceholder(/search/i)
|
|
34
|
+
const searchVisible = await search.isVisible().catch(() => false)
|
|
35
|
+
if (!searchVisible) return
|
|
36
|
+
|
|
37
|
+
const dialogFired: string[] = []
|
|
38
|
+
authenticatedPage.on('dialog', async (dialog) => {
|
|
39
|
+
dialogFired.push(dialog.message())
|
|
40
|
+
await dialog.dismiss()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
for (const payload of XSS_PAYLOADS) {
|
|
44
|
+
await search.clear()
|
|
45
|
+
await search.fill(payload)
|
|
46
|
+
await search.press('Enter')
|
|
47
|
+
await authenticatedPage.waitForTimeout(200)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
expect(dialogFired).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('URL parameters do not trigger XSS', async ({ authenticatedPage }) => {
|
|
54
|
+
const dialogFired: string[] = []
|
|
55
|
+
authenticatedPage.on('dialog', async (dialog) => {
|
|
56
|
+
dialogFired.push(dialog.message())
|
|
57
|
+
await dialog.dismiss()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Test XSS via URL params
|
|
61
|
+
const xssUrls = [
|
|
62
|
+
`${ROUTES.knowledge}?search=<script>alert('xss')</script>`,
|
|
63
|
+
`${ROUTES.knowledge}?search="><img src=x onerror=alert('xss')>`,
|
|
64
|
+
`${ROUTES.home}?tab=<script>alert(1)</script>`,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
for (const url of xssUrls) {
|
|
68
|
+
await authenticatedPage.goto(url)
|
|
69
|
+
await authenticatedPage.waitForTimeout(500)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
expect(dialogFired).toHaveLength(0)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Page, ConsoleMessage } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
export type CollectedMessage = {
|
|
4
|
+
type: string
|
|
5
|
+
text: string
|
|
6
|
+
url: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const SENSITIVE_PATTERNS = [
|
|
10
|
+
/password/i,
|
|
11
|
+
/secret/i,
|
|
12
|
+
/token/i,
|
|
13
|
+
/api[_-]?key/i,
|
|
14
|
+
/authorization:\s*bearer/i,
|
|
15
|
+
/session[_-]?id/i,
|
|
16
|
+
/cookie/i,
|
|
17
|
+
/credential/i,
|
|
18
|
+
/private[_-]?key/i,
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const STACK_TRACE_PATTERNS = [
|
|
22
|
+
/at\s+\S+\s+\(\S+:\d+:\d+\)/,
|
|
23
|
+
/Error:.*\n\s+at\s/,
|
|
24
|
+
/^\s+at\s+/m,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Attaches a console listener that collects all console messages.
|
|
29
|
+
* Returns helpers to assert no sensitive data or stack traces were logged.
|
|
30
|
+
*/
|
|
31
|
+
export function createConsoleCollector(page: Page) {
|
|
32
|
+
const messages: CollectedMessage[] = []
|
|
33
|
+
|
|
34
|
+
page.on('console', (msg: ConsoleMessage) => {
|
|
35
|
+
messages.push({
|
|
36
|
+
type: msg.type(),
|
|
37
|
+
text: msg.text(),
|
|
38
|
+
url: page.url(),
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
getMessages: () => [...messages],
|
|
44
|
+
|
|
45
|
+
getSensitiveLeaks: () =>
|
|
46
|
+
messages.filter((m) =>
|
|
47
|
+
SENSITIVE_PATTERNS.some((pattern) => pattern.test(m.text)),
|
|
48
|
+
),
|
|
49
|
+
|
|
50
|
+
getStackTraces: () =>
|
|
51
|
+
messages.filter((m) =>
|
|
52
|
+
STACK_TRACE_PATTERNS.some((pattern) => pattern.test(m.text)),
|
|
53
|
+
),
|
|
54
|
+
|
|
55
|
+
getErrors: () => messages.filter((m) => m.type === 'error'),
|
|
56
|
+
|
|
57
|
+
assertNoSensitiveLeaks: () => {
|
|
58
|
+
const leaks = messages.filter((m) =>
|
|
59
|
+
SENSITIVE_PATTERNS.some((pattern) => pattern.test(m.text)),
|
|
60
|
+
)
|
|
61
|
+
if (leaks.length > 0) {
|
|
62
|
+
const details = leaks
|
|
63
|
+
.map((l) => `[${l.type}] ${l.text.substring(0, 100)}`)
|
|
64
|
+
.join('\n')
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Found ${leaks.length} potential sensitive data leak(s) in console:\n${details}`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
assertNoStackTraces: () => {
|
|
72
|
+
const traces = messages.filter((m) =>
|
|
73
|
+
STACK_TRACE_PATTERNS.some((pattern) => pattern.test(m.text)),
|
|
74
|
+
)
|
|
75
|
+
if (traces.length > 0) {
|
|
76
|
+
const details = traces
|
|
77
|
+
.map((t) => `[${t.type}] ${t.text.substring(0, 200)}`)
|
|
78
|
+
.join('\n')
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Found ${traces.length} stack trace(s) in console:\n${details}`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
clear: () => {
|
|
86
|
+
messages.length = 0
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|