Skip to content

Commit d26b399

Browse files
authored
Merge pull request #562 from Chris0Jeky/fix/524-feature-flag-route-guard
fix: enforce feature-flag gate in route guard (#524)
2 parents 37a5079 + ca27987 commit d26b399

File tree

3 files changed

+178
-14
lines changed

3 files changed

+178
-14
lines changed

frontend/taskdeck-web/src/router/index.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ import { isTokenExpired } from '../utils/jwt'
55
import { isDemoMode, isDemoSessionActive } from '../utils/demoMode'
66
import * as tokenStorage from '../utils/tokenStorage'
77
import { usePerformanceMark } from '../composables/usePerformanceMark'
8+
import { useFeatureFlagStore } from '../store/featureFlagStore'
9+
import type { FeatureFlags } from '../types/feature-flags'
10+
11+
// Augment vue-router's RouteMeta so that `requiresFlag` is type-safe throughout.
12+
declare module 'vue-router' {
13+
interface RouteMeta {
14+
public?: boolean
15+
requiresShell?: boolean
16+
automationSurface?: string
17+
requiresFlag?: keyof FeatureFlags
18+
}
19+
}
820

921
// Lazy-loaded route components — keeps initial bundle small and speeds up
1022
// first-paint for login/register (the only eagerly-loaded views).
@@ -92,25 +104,25 @@ const router = createRouter({
92104
path: '/workspace/activity',
93105
name: 'workspace-activity',
94106
component: ActivityView,
95-
meta: { requiresShell: true },
107+
meta: { requiresShell: true, requiresFlag: 'newActivity' },
96108
},
97109
{
98110
path: '/workspace/activity/board/:boardId',
99111
name: 'workspace-activity-board',
100112
component: ActivityView,
101-
meta: { requiresShell: true },
113+
meta: { requiresShell: true, requiresFlag: 'newActivity' },
102114
},
103115
{
104116
path: '/workspace/activity/entity/:entityType/:entityId',
105117
name: 'workspace-activity-entity',
106118
component: ActivityView,
107-
meta: { requiresShell: true },
119+
meta: { requiresShell: true, requiresFlag: 'newActivity' },
108120
},
109121
{
110122
path: '/workspace/activity/user',
111123
name: 'workspace-activity-user',
112124
component: ActivityView,
113-
meta: { requiresShell: true },
125+
meta: { requiresShell: true, requiresFlag: 'newActivity' },
114126
},
115127
{
116128
path: '/workspace/activity/user/:userId',
@@ -130,13 +142,13 @@ const router = createRouter({
130142
path: '/workspace/automations/queue',
131143
name: 'workspace-automations-queue',
132144
component: AutomationQueueView,
133-
meta: { requiresShell: true, automationSurface: 'queue' },
145+
meta: { requiresShell: true, automationSurface: 'queue', requiresFlag: 'newAutomation' },
134146
},
135147
{
136148
path: '/workspace/review',
137149
name: 'workspace-review',
138150
component: ReviewView,
139-
meta: { requiresShell: true, automationSurface: 'review' },
151+
meta: { requiresShell: true, automationSurface: 'review', requiresFlag: 'newAutomation' },
140152
},
141153
{
142154
path: '/workspace/automations/proposals',
@@ -150,35 +162,35 @@ const router = createRouter({
150162
path: '/workspace/automations/chat',
151163
name: 'workspace-automations-chat',
152164
component: AutomationChatView,
153-
meta: { requiresShell: true },
165+
meta: { requiresShell: true, requiresFlag: 'newAutomation' },
154166
},
155167

156168
// Ops routes
157169
{
158170
path: '/workspace/ops/cli',
159171
name: 'workspace-ops-cli',
160172
component: OpsConsoleView,
161-
meta: { requiresShell: true },
173+
meta: { requiresShell: true, requiresFlag: 'newOps' },
162174
},
163175
{
164176
path: '/workspace/ops/endpoints',
165177
name: 'workspace-ops-endpoints',
166178
component: OpsConsoleView,
167-
meta: { requiresShell: true },
179+
meta: { requiresShell: true, requiresFlag: 'newOps' },
168180
},
169181
{
170182
path: '/workspace/ops/logs',
171183
name: 'workspace-ops-logs',
172184
component: OpsConsoleView,
173-
meta: { requiresShell: true },
185+
meta: { requiresShell: true, requiresFlag: 'newOps' },
174186
},
175187

176188
// Settings routes
177189
{
178190
path: '/workspace/settings/profile',
179191
name: 'workspace-settings-profile',
180192
component: ProfileSettingsView,
181-
meta: { requiresShell: true },
193+
meta: { requiresShell: true, requiresFlag: 'newAuth' },
182194
},
183195
{
184196
path: '/workspace/settings/access/:boardId?',
@@ -187,7 +199,7 @@ const router = createRouter({
187199
props: (route) => ({
188200
boardId: typeof route.params.boardId === 'string' ? route.params.boardId : null,
189201
}),
190-
meta: { requiresShell: true },
202+
meta: { requiresShell: true, requiresFlag: 'newAccess' },
191203
},
192204
{
193205
path: '/workspace/settings/export-import',
@@ -207,7 +219,7 @@ const router = createRouter({
207219
path: '/workspace/archive',
208220
name: 'workspace-archive',
209221
component: ArchiveView,
210-
meta: { requiresShell: true },
222+
meta: { requiresShell: true, requiresFlag: 'newArchive' },
211223
},
212224
{
213225
path: '/workspace/inbox',
@@ -227,7 +239,7 @@ const router = createRouter({
227239
// Route-transition performance instrumentation
228240
const routePerf = usePerformanceMark('route-transition')
229241

230-
// Navigation guard for auth
242+
// Navigation guard for auth and feature flags
231243
router.beforeEach((to) => {
232244
routePerf.start()
233245

@@ -248,6 +260,19 @@ router.beforeEach((to) => {
248260
if (isPublic && hasValidSession && (to.path === '/login' || to.path === '/register')) {
249261
return { path: '/workspace/home' }
250262
}
263+
264+
// Feature-flag gate: block direct URL access to flagged routes when the flag
265+
// is disabled. The store is read synchronously from localStorage on first
266+
// access so the guard works correctly on hard refresh before App.vue mounts.
267+
const requiredFlag = to.meta.requiresFlag
268+
if (requiredFlag !== undefined) {
269+
const featureFlags = useFeatureFlagStore()
270+
// Restore from localStorage in case App.vue hasn't mounted yet (direct nav).
271+
featureFlags.restore()
272+
if (!featureFlags.isEnabled(requiredFlag)) {
273+
return { path: '/workspace/home' }
274+
}
275+
}
251276
})
252277

253278
router.afterEach(() => {
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Tests for the feature-flag route guard logic (issue #524).
3+
*
4+
* The guard in router/index.ts reads `to.meta.requiresFlag` and redirects to
5+
* /workspace/home when the flag is disabled. These tests exercise the
6+
* featureFlagStore behaviour that the guard depends on, and verify the guard's
7+
* decision table directly via a simulated meta object — without needing a full
8+
* vue-router instance.
9+
*/
10+
import { describe, it, expect, beforeEach } from 'vitest'
11+
import { setActivePinia, createPinia } from 'pinia'
12+
import { useFeatureFlagStore } from '../../store/featureFlagStore'
13+
import type { FeatureFlags } from '../../types/feature-flags'
14+
15+
// ─── helper that mirrors the guard's decision logic ───────────────────────────
16+
function guardDecision(
17+
meta: { requiresFlag?: keyof FeatureFlags },
18+
store: ReturnType<typeof useFeatureFlagStore>,
19+
): { path: string } | undefined {
20+
const requiredFlag = meta.requiresFlag
21+
if (requiredFlag !== undefined) {
22+
store.restore()
23+
if (!store.isEnabled(requiredFlag)) {
24+
return { path: '/workspace/home' }
25+
}
26+
}
27+
return undefined // allow navigation
28+
}
29+
30+
// ─── routes that are gated by feature flags (mirrors router/index.ts) ─────────
31+
const FLAGGED_ROUTES: { path: string; flag: keyof FeatureFlags }[] = [
32+
{ path: '/workspace/activity', flag: 'newActivity' },
33+
{ path: '/workspace/activity/board/123', flag: 'newActivity' },
34+
{ path: '/workspace/activity/entity/task/456', flag: 'newActivity' },
35+
{ path: '/workspace/activity/user', flag: 'newActivity' },
36+
{ path: '/workspace/automations/queue', flag: 'newAutomation' },
37+
{ path: '/workspace/review', flag: 'newAutomation' },
38+
{ path: '/workspace/automations/chat', flag: 'newAutomation' },
39+
{ path: '/workspace/ops/cli', flag: 'newOps' },
40+
{ path: '/workspace/ops/endpoints', flag: 'newOps' },
41+
{ path: '/workspace/ops/logs', flag: 'newOps' },
42+
{ path: '/workspace/settings/profile', flag: 'newAuth' },
43+
{ path: '/workspace/settings/access', flag: 'newAccess' },
44+
{ path: '/workspace/archive', flag: 'newArchive' },
45+
]
46+
47+
describe('feature-flag route guard (#524)', () => {
48+
let store: ReturnType<typeof useFeatureFlagStore>
49+
50+
beforeEach(() => {
51+
setActivePinia(createPinia())
52+
localStorage.clear()
53+
store = useFeatureFlagStore()
54+
})
55+
56+
describe('routes without a flag requirement', () => {
57+
it('allows navigation when meta.requiresFlag is undefined', () => {
58+
expect(guardDecision({}, store)).toBeUndefined()
59+
})
60+
})
61+
62+
describe('routes with a flag requirement — flag enabled', () => {
63+
it.each(FLAGGED_ROUTES)(
64+
'allows $path when $flag is enabled',
65+
({ path: _path, flag }) => {
66+
store.setFlag(flag, true)
67+
expect(guardDecision({ requiresFlag: flag }, store)).toBeUndefined()
68+
},
69+
)
70+
})
71+
72+
describe('routes with a flag requirement — flag disabled', () => {
73+
it.each(FLAGGED_ROUTES)(
74+
'redirects $path to /workspace/home when $flag is disabled',
75+
({ path: _path, flag }) => {
76+
store.setFlag(flag, false)
77+
expect(guardDecision({ requiresFlag: flag }, store)).toEqual({
78+
path: '/workspace/home',
79+
})
80+
},
81+
)
82+
})
83+
84+
describe('hard-refresh scenario — restore() called by guard before check', () => {
85+
it('reads disabled flag from localStorage before App.vue mounts', () => {
86+
// Persist a disabled flag directly to localStorage (simulating a prior session).
87+
localStorage.setItem(
88+
'taskdeck_feature_flags',
89+
JSON.stringify({ newOps: false }),
90+
)
91+
// A fresh store instance starts with defaults — newOps default is false, but
92+
// let's explicitly test that restore() picks up what localStorage says.
93+
setActivePinia(createPinia())
94+
const freshStore = useFeatureFlagStore()
95+
// Guard calls restore() before isEnabled() — simulate that.
96+
expect(guardDecision({ requiresFlag: 'newOps' }, freshStore)).toEqual({
97+
path: '/workspace/home',
98+
})
99+
})
100+
101+
it('reads enabled flag from localStorage before App.vue mounts', () => {
102+
localStorage.setItem(
103+
'taskdeck_feature_flags',
104+
JSON.stringify({ newActivity: true }),
105+
)
106+
setActivePinia(createPinia())
107+
const freshStore = useFeatureFlagStore()
108+
expect(guardDecision({ requiresFlag: 'newActivity' }, freshStore)).toBeUndefined()
109+
})
110+
})
111+
112+
describe('guard coverage — all flagged routes have a flag entry', () => {
113+
it('FLAGGED_ROUTES list is non-empty', () => {
114+
expect(FLAGGED_ROUTES.length).toBeGreaterThan(0)
115+
})
116+
117+
it('every route in FLAGGED_ROUTES references a valid FeatureFlags key', () => {
118+
// If a key is invalid, TypeScript would have already caught it, but this
119+
// runtime check guards against future key renames at the type level.
120+
const validFlags = [
121+
'newShell', 'newAuth', 'newAccess', 'newActivity',
122+
'newOps', 'newAutomation', 'newArchive',
123+
] as const
124+
for (const { flag } of FLAGGED_ROUTES) {
125+
expect(validFlags).toContain(flag)
126+
}
127+
})
128+
})
129+
})

frontend/taskdeck-web/tests/e2e/support/authSession.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ export async function attachSessionToPage(page: Page, auth: AuthResult): Promise
4949
await page.addInitScript((initPayload: { token: string; session: { userId: string; username: string; email: string } }) => {
5050
localStorage.setItem('taskdeck_token', initPayload.token)
5151
localStorage.setItem('taskdeck_session', JSON.stringify(initPayload.session))
52+
// Enable all feature flags so E2E tests can reach gated routes
53+
localStorage.setItem('taskdeck_feature_flags', JSON.stringify({
54+
newShell: true,
55+
newAuth: true,
56+
newAccess: true,
57+
newActivity: true,
58+
newOps: true,
59+
newAutomation: true,
60+
newArchive: true,
61+
}))
5262
}, payload)
5363
}
5464

0 commit comments

Comments
 (0)