UNPKG

unleash-server

Version:

Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.

379 lines • 16.6 kB
import { createUserWithRootRole, setupAppWithAuth, } from '../../helpers/test-helper.js'; import dbInit from '../../helpers/database-init.js'; import getLogger from '../../../fixtures/no-logger.js'; import { CHANGE_REQUEST_CREATED } from '../../../../lib/events/index.js'; import { CLIENT, DEFAULT_ENV } from '../../../../lib/server-impl.js'; import { ApiTokenType } from '../../../../lib/types/model.js'; import { RoleName, TEST_AUDIT_USER } from '../../../../lib/types/index.js'; const validTokens = [ { tokenName: `client-dev-token`, permissions: [CLIENT], projects: ['*'], environment: 'development', type: ApiTokenType.CLIENT, secret: '*:development.client', }, { tokenName: `client-dev-default-project`, permissions: [CLIENT], projects: ['default'], environment: 'development', type: ApiTokenType.CLIENT, secret: 'default:development.default-only', }, { tokenName: `client-prod-token`, permissions: [CLIENT], projects: ['*'], environment: 'production', type: ApiTokenType.CLIENT, secret: '*:production.client', }, { tokenName: 'all-envs-client', permissions: [CLIENT], projects: ['*'], environment: '*', type: ApiTokenType.CLIENT, secret: '*:*.hungry-client', }, ]; const devTokenSecret = validTokens[0].secret; const devDefaultProjectTokenSecret = validTokens[1].secret; const prodTokenSecret = validTokens[2].secret; const allEnvsTokenSecret = validTokens[3].secret; let adminPat; async function setup() { const db = await dbInit(`ignored`, getLogger); // Create per-environment client tokens so we can request specific environment snapshots const app = await setupAppWithAuth(db.stores, { authentication: { enableApiToken: true, initApiTokens: validTokens, }, experimental: { flags: { strictSchemaValidation: true, }, }, }, db.rawDatabase); return { app, db }; } async function initialize({ app, db }) { const allEnvs = await app.services.environmentService.getAll(); const nonDefaultEnv = allEnvs.find((env) => env.name !== DEFAULT_ENV).name; await app.createFeature('X'); await app.createFeature('Y'); await app.archiveFeature('Y'); await app.createFeature('Z'); await app.enableFeature('Z', DEFAULT_ENV); await app.enableFeature('Z', nonDefaultEnv); await app.services.eventService.storeEvent({ type: CHANGE_REQUEST_CREATED, createdBy: 'some@example.com', createdByUserId: 123, ip: '127.0.0.1', featureName: `X`, }); const user = await createUserWithRootRole({ app, stores: db.stores, email: 'admin@example.com', roleName: RoleName.ADMIN, }); const { secret } = await app.services.patService.createPat({ description: 'e2e test token', expiresAt: new Date(Date.now() + 1000 * 60 * 60).toISOString(), }, user.id, TEST_AUDIT_USER); adminPat = secret; } describe('feature 304 api client', () => { let app; let db; beforeAll(async () => { ({ app, db } = await setup()); await initialize({ app, db }); }); afterAll(async () => { await app.destroy(); await db.destroy(); }); test('returns etag and echoes it in meta (dev env token)', async () => { const res = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .expect('Content-Type', /json/) .expect(200); expect(res.headers.etag).toBeDefined(); expect(res.body.meta.etag).toBe(res.headers.etag); }); test(`returns 304 when client presents current etag (dev env token)`, async () => { const initial = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .expect(200); const initialEtag = initial.headers.etag; await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .set('if-none-match', initialEtag) .expect(304); }); test('creating a new feature modifies etag', async () => { const baseline = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .expect(200); const oldEtag = baseline.headers.etag; await app.createFeature('new'); await app.services.configurationRevisionService.updateMaxRevisionId(); const res = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .set('if-none-match', `${oldEtag}`) .expect(200); expect(res.headers.etag).not.toBe(oldEtag); expect(res.body.meta.etag).not.toBe(oldEtag); expect(res.body.features.map((f) => f.name)).toContain('new'); }); test('all-env token has its own etag and caches correctly', async () => { const prod = await app.request .get('/api/client/features') .set('Authorization', prodTokenSecret) .expect(200); const dev = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .expect(200); const allEnvs = await app.request .get('/api/client/features') .set('Authorization', allEnvsTokenSecret) .expect(200); expect(allEnvs.body.meta.etag).toBe(allEnvs.headers.etag); expect(allEnvs.headers.etag).not.toBe(prod.headers.etag); expect(allEnvs.headers.etag).not.toBe(dev.headers.etag); await app.request .get('/api/client/features') .set('Authorization', allEnvsTokenSecret) .set('if-none-match', allEnvs.headers.etag) .expect(304); }); test('production environment gets a different etag than development', async () => { const prod = await app.request .get('/api/client/features?bla=1') .set('Authorization', prodTokenSecret) .expect(200); const dev = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .expect(200); expect(prod.body.meta.etag).toBe(prod.headers.etag); expect(dev.body.meta.etag).toBe(dev.headers.etag); expect(prod.headers.etag).not.toBe(dev.headers.etag); }); test('modifying dev environment should only invalidate dev tokens', async () => { const prodBaseline = await app.request .get('/api/client/features') .set('Authorization', prodTokenSecret) .expect(200); const devBaseline = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .expect(200); await app.request .get('/api/client/features') .set('if-none-match', prodBaseline.headers.etag) .set('Authorization', prodTokenSecret) .expect(304); await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .set('if-none-match', devBaseline.headers.etag) .expect(304); await app.enableFeature('X', DEFAULT_ENV); await app.services.configurationRevisionService.updateMaxRevisionId(); await app.request .get('/api/client/features') .set('Authorization', prodTokenSecret) .set('if-none-match', prodBaseline.headers.etag) .expect(304); const { headers: devHeaders } = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .set('if-none-match', devBaseline.headers.etag) .expect(200); expect(devHeaders.etag).not.toBe(devBaseline.headers.etag); }); test('archiving a feature removes it from client response and updates etag', async () => { const featureName = 'temp-archive'; await app.createFeature(featureName); await app.enableFeature(featureName, DEFAULT_ENV); await app.services.configurationRevisionService.updateMaxRevisionId(); const first = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .expect(200); const firstEtag = first.headers.etag; expect(first.body.features.map((f) => f.name)).toContain(featureName); await app.archiveFeature(featureName); await app.services.configurationRevisionService.updateMaxRevisionId(); const second = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .set('if-none-match', firstEtag) .expect(200); expect(second.headers.etag).not.toBe(firstEtag); expect(second.body.features.map((f) => f.name)).not.toContain(featureName); }); test('favoriting/unfavoriting a feature does not change etag', async () => { const featureName = 'X'; const baseline = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .expect(200); const etag = baseline.headers.etag; await app.request .post(`/api/admin/projects/default/features/${featureName}/favorites`) .set('Authorization', `${adminPat}`) .set('Content-Type', 'application/json') .expect(200); await app.services.configurationRevisionService.updateMaxRevisionId(); await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .set('if-none-match', etag) .expect(304); await app.request .delete(`/api/admin/projects/default/features/${featureName}/favorites`) .set('Authorization', `${adminPat}`) .set('Content-Type', 'application/json') .expect(200); await app.services.configurationRevisionService.updateMaxRevisionId(); await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .set('if-none-match', etag) .expect(304); }); test('adding a feature link does not change etag', async () => { const featureName = 'X'; const baseline = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .expect(200); const etag = baseline.headers.etag; await app.services.featureLinkService.createLink('default', { featureName, title: 'Docs', url: 'https://example.com/docs', }, TEST_AUDIT_USER); await app.services.configurationRevisionService.updateMaxRevisionId(); await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .set('if-none-match', etag) .expect(304); }); test('tagging and untagging a feature updates etag for tag-filtered clients', async () => { const featureName = 'X'; const tag = { type: 'simple', value: 'Crazy' }; const baseline = await app.request .get('/api/client/features?tag=simple:Crazy') .set('Authorization', devTokenSecret) .expect(200); const baselineEtag = baseline.headers.etag; expect(baseline.body.features.map((f) => f.name)).not.toContain(featureName); await app.request .post(`/api/admin/features/${featureName}/tags`) .set('Authorization', `${adminPat}`) .set('Content-Type', 'application/json') .send(tag) .expect(201); await app.services.configurationRevisionService.updateMaxRevisionId(); const afterTag = await app.request .get('/api/client/features?tag=simple:Crazy') .set('Authorization', devTokenSecret) .set('if-none-match', baselineEtag) .expect(200); expect(afterTag.body.features.map((f) => f.name)).toContain(featureName); const tagEtag = afterTag.headers.etag; await app.request .delete(`/api/admin/features/${featureName}/tags/${tag.type}/${tag.value}`) .set('Authorization', `${adminPat}`) .expect(200); await app.services.configurationRevisionService.updateMaxRevisionId(); await app.request .get('/api/client/features?tag=simple:Crazy') .set('Authorization', devTokenSecret) .set('if-none-match', tagEtag) .expect(200) .expect((res) => expect(res.body.features.map((f) => f.name)).not.toContain(featureName)); }); test('deleting an archived feature updates etag', async () => { const featureName = 'temp-delete'; await app.createFeature(featureName); await app.enableFeature(featureName, DEFAULT_ENV); await app.services.configurationRevisionService.updateMaxRevisionId(); const initial = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .expect(200); const initialEtag = initial.headers.etag; expect(initial.body.features.map((f) => f.name)).toContain(featureName); await app.archiveFeature(featureName); await app.services.configurationRevisionService.updateMaxRevisionId(); const afterArchive = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .set('if-none-match', initialEtag) .expect(200); const archiveEtag = afterArchive.headers.etag; expect(archiveEtag).not.toBe(initialEtag); expect(afterArchive.body.features.map((f) => f.name)).not.toContain(featureName); await app.request .delete(`/api/admin/archive/${featureName}`) .expect(200); await app.services.configurationRevisionService.updateMaxRevisionId(); const afterDelete = await app.request .get('/api/client/features') .set('Authorization', devTokenSecret) .set('if-none-match', archiveEtag) .expect(200); expect(afterDelete.headers.etag).not.toBe(archiveEtag); expect(afterDelete.body.meta.etag).toBe(afterDelete.headers.etag); expect(afterDelete.body.features.map((f) => f.name)).not.toContain(featureName); }); test('moving a feature to another project updates project field and etag', async () => { const featureName = 'temp-move'; const targetProject = 'second-project'; // ensure target project exists const adminUser = await app.services.userService.createUser({ name: 'Move Admin', email: 'move-admin@getunleash.io', rootRole: RoleName.ADMIN, }, TEST_AUDIT_USER); await app.services.projectService.createProject({ id: targetProject, name: targetProject, mode: 'open' }, adminUser, TEST_AUDIT_USER); await app.createFeature(featureName); await app.enableFeature(featureName, DEFAULT_ENV); await app.services.configurationRevisionService.updateMaxRevisionId(); const initial = await app.request .get('/api/client/features') .set('Authorization', devDefaultProjectTokenSecret) .expect(200); const initialEtag = initial.headers.etag; const initialProject = initial.body.features.find((f) => f.name === featureName)?.project; expect(initialProject).toBe('default'); await app.services.featureToggleService.changeProject(featureName, targetProject, TEST_AUDIT_USER); await app.services.configurationRevisionService.updateMaxRevisionId(); const afterMove = await app.request .get('/api/client/features') .set('Authorization', devDefaultProjectTokenSecret) .set('if-none-match', initialEtag) .expect(200); expect(afterMove.headers.etag).not.toBe(initialEtag); const movedFeature = afterMove.body.features.find((f) => f.name === featureName); expect(movedFeature).toBeUndefined(); }); }); //# sourceMappingURL=feature.optimal304.e2e.test.js.map