1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-27 13:49:10 +02:00

feat: support different etags per environment (#10512)

## About the changes
This PR introduces environment-specific etags. This way clients will not
react by updating features when there are changes in environments the
SDK doesn't care about.

## Details
There's a bit of scouting work (please don't make me split this 🙏)
and other details are in comments, but the most relevant for the lazy
ones:
- Important **decision** on how we detect changes, unifying polling and
delta:
https://github.com/Unleash/unleash/pull/10512#discussion_r2285677129
- **Decision** on how we update revision id per environment:
https://github.com/Unleash/unleash/pull/10512#discussion_r2291888401
- and how we do initial fetch on the read path:
https://github.com/Unleash/unleash/pull/10512#discussion_r2291884777
- The singleton pattern that gave me **nightmares**:
https://github.com/Unleash/unleash/pull/10512#discussion_r2291848934
- **Do we still have ALL_ENVS tokens?**
https://github.com/Unleash/unleash/pull/10512#discussion_r2291913249

## Feature flag
To control the rollout introduced `etagByEnv` feature:
[0da567d](0da567dd9b)
This commit is contained in:
Gastón Fournier 2025-08-22 06:35:17 -07:00 committed by GitHub
parent 885d3e1817
commit 7ea0c9ca9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 473 additions and 245 deletions

View File

@ -9,7 +9,7 @@ import {
corsOriginMiddleware, corsOriginMiddleware,
} from './middleware/index.js'; } from './middleware/index.js';
import rbacMiddleware from './middleware/rbac-middleware.js'; import rbacMiddleware from './middleware/rbac-middleware.js';
import apiTokenMiddleware from './middleware/api-token-middleware.js'; import { apiAccessMiddleware } from './middleware/api-token-middleware.js';
import type { IUnleashServices } from './services/index.js'; import type { IUnleashServices } from './services/index.js';
import { IAuthType, type IUnleashConfig } from './types/option.js'; import { IAuthType, type IUnleashConfig } from './types/option.js';
import type { IUnleashStores } from './types/index.js'; import type { IUnleashStores } from './types/index.js';
@ -116,26 +116,26 @@ export default async function getApp(
switch (config.authentication.type) { switch (config.authentication.type) {
case IAuthType.OPEN_SOURCE: { case IAuthType.OPEN_SOURCE: {
app.use(baseUriPath, apiTokenMiddleware(config, services)); app.use(baseUriPath, apiAccessMiddleware(config, services));
ossAuthentication(app, config.getLogger, config.server.baseUriPath); ossAuthentication(app, config.getLogger, config.server.baseUriPath);
break; break;
} }
case IAuthType.ENTERPRISE: { case IAuthType.ENTERPRISE: {
app.use(baseUriPath, apiTokenMiddleware(config, services)); app.use(baseUriPath, apiAccessMiddleware(config, services));
if (config.authentication.customAuthHandler) { if (config.authentication.customAuthHandler) {
config.authentication.customAuthHandler(app, config, services); config.authentication.customAuthHandler(app, config, services);
} }
break; break;
} }
case IAuthType.HOSTED: { case IAuthType.HOSTED: {
app.use(baseUriPath, apiTokenMiddleware(config, services)); app.use(baseUriPath, apiAccessMiddleware(config, services));
if (config.authentication.customAuthHandler) { if (config.authentication.customAuthHandler) {
config.authentication.customAuthHandler(app, config, services); config.authentication.customAuthHandler(app, config, services);
} }
break; break;
} }
case IAuthType.DEMO: { case IAuthType.DEMO: {
app.use(baseUriPath, apiTokenMiddleware(config, services)); app.use(baseUriPath, apiAccessMiddleware(config, services));
demoAuthentication( demoAuthentication(
app, app,
config.server.baseUriPath, config.server.baseUriPath,
@ -145,7 +145,7 @@ export default async function getApp(
break; break;
} }
case IAuthType.CUSTOM: { case IAuthType.CUSTOM: {
app.use(baseUriPath, apiTokenMiddleware(config, services)); app.use(baseUriPath, apiAccessMiddleware(config, services));
if (config.authentication.customAuthHandler) { if (config.authentication.customAuthHandler) {
config.authentication.customAuthHandler(app, config, services); config.authentication.customAuthHandler(app, config, services);
} }
@ -156,12 +156,12 @@ export default async function getApp(
'The AuthType=none option for Unleash is no longer recommended and will be removed in version 6.', 'The AuthType=none option for Unleash is no longer recommended and will be removed in version 6.',
); );
noApiToken(baseUriPath, app); noApiToken(baseUriPath, app);
app.use(baseUriPath, apiTokenMiddleware(config, services)); app.use(baseUriPath, apiAccessMiddleware(config, services));
noAuthentication(baseUriPath, app); noAuthentication(baseUriPath, app);
break; break;
} }
default: { default: {
app.use(baseUriPath, apiTokenMiddleware(config, services)); app.use(baseUriPath, apiAccessMiddleware(config, services));
demoAuthentication( demoAuthentication(
app, app,
config.server.baseUriPath, config.server.baseUriPath,

View File

@ -357,11 +357,12 @@ export default class FeatureController extends Controller {
} }
async calculateMeta(query: IFeatureToggleQuery): Promise<IMeta> { async calculateMeta(query: IFeatureToggleQuery): Promise<IMeta> {
// TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?). const etagByEnvEnabled = this.flagResolver.isEnabled('etagByEnv');
const revisionId = const revisionId =
await this.configurationRevisionService.getMaxRevisionId(); await this.configurationRevisionService.getMaxRevisionId(
etagByEnvEnabled ? query.environment : undefined,
);
// TODO: We will need to standardize this to be able to implement this a cross languages (Edge in Rust?).
const queryHash = hashSum(query); const queryHash = hashSum(query);
const etagVariant = this.flagResolver.getVariant('etagVariant'); const etagVariant = this.flagResolver.getVariant('etagVariant');
if (etagVariant.feature_enabled && etagVariant.enabled) { if (etagVariant.feature_enabled && etagVariant.enabled) {

View File

@ -15,7 +15,7 @@ import type {
IEventSearchParams, IEventSearchParams,
IEventStore, IEventStore,
} from '../../types/stores/event-store.js'; } from '../../types/stores/event-store.js';
import { sharedEventEmitter } from '../../util/index.js'; import { ALL_ENVS, sharedEventEmitter } from '../../util/index.js';
import type { Db } from '../../db/db.js'; import type { Db } from '../../db/db.js';
import type { Knex } from 'knex'; import type { Knex } from 'knex';
import type EventEmitter from 'node:events'; import type EventEmitter from 'node:events';
@ -191,33 +191,44 @@ export class EventStore implements IEventStore {
} }
} }
async getMaxRevisionId(largerThan: number = 0): Promise<number> { private eventTypeIsInteresting =
(opts?: { additionalTypes?: string[]; environment?: string }) =>
(builder: Knex.QueryBuilder) =>
builder
.andWhere((inner) => {
inner
.whereNotNull('feature_name')
.whereNotIn('type', [FEATURE_CREATED, FEATURE_TAGGED])
.whereNot('type', 'LIKE', 'change-%');
if (opts?.environment && opts.environment !== ALL_ENVS) {
inner.where('environment', opts.environment);
}
return inner;
})
.orWhereIn('type', [
SEGMENT_UPDATED,
FEATURE_IMPORT,
FEATURES_IMPORTED,
...(opts?.additionalTypes ?? []),
]);
/** This method is used for polling */
async getMaxRevisionId(
largerThan: number = 0,
environment?: string,
): Promise<number> {
const stopTimer = this.metricTimer('getMaxRevisionId'); const stopTimer = this.metricTimer('getMaxRevisionId');
const row = await this.db(TABLE) const row = await this.db(TABLE)
.max('id') .max('id')
.where((builder) => .where(this.eventTypeIsInteresting({ environment }))
builder
.andWhere((inner) =>
inner
.whereNotNull('feature_name')
.whereNotIn('type', [
FEATURE_CREATED,
FEATURE_TAGGED,
])
.whereNot('type', 'LIKE', 'change-%'),
)
.orWhereIn('type', [
SEGMENT_UPDATED,
FEATURE_IMPORT,
FEATURES_IMPORTED,
]),
)
.andWhere('id', '>=', largerThan) .andWhere('id', '>=', largerThan)
.first(); .first();
stopTimer(); stopTimer();
return row?.max ?? 0; return row?.max ?? 0;
} }
/** This method is used for delta/streaming */
async getRevisionRange(start: number, end: number): Promise<IEvent[]> { async getRevisionRange(start: number, end: number): Promise<IEvent[]> {
const stopTimer = this.metricTimer('getRevisionRange'); const stopTimer = this.metricTimer('getRevisionRange');
const query = this.db const query = this.db
@ -225,27 +236,15 @@ export class EventStore implements IEventStore {
.from(TABLE) .from(TABLE)
.where('id', '>', start) .where('id', '>', start)
.andWhere('id', '<=', end) .andWhere('id', '<=', end)
.andWhere((builder) => .andWhere(
builder this.eventTypeIsInteresting({
.andWhere((inner) => additionalTypes: [SEGMENT_CREATED, SEGMENT_DELETED],
inner }),
.whereNotNull('feature_name')
.whereNotIn('type', [
FEATURE_CREATED,
FEATURE_TAGGED,
]),
)
.orWhereIn('type', [
SEGMENT_UPDATED,
FEATURE_IMPORT,
FEATURES_IMPORTED,
SEGMENT_CREATED,
SEGMENT_DELETED,
]),
) )
.orderBy('id', 'asc'); .orderBy('id', 'asc');
const rows = await query; const rows = await query;
stopTimer();
return rows.map(this.rowToEvent); return rows.map(this.rowToEvent);
} }

View File

@ -10,7 +10,7 @@ import EventEmitter from 'events';
export const UPDATE_REVISION = 'UPDATE_REVISION'; export const UPDATE_REVISION = 'UPDATE_REVISION';
export default class ConfigurationRevisionService extends EventEmitter { export default class ConfigurationRevisionService extends EventEmitter {
private static instance: ConfigurationRevisionService; private static instance: ConfigurationRevisionService | undefined;
private logger: Logger; private logger: Logger;
@ -18,6 +18,8 @@ export default class ConfigurationRevisionService extends EventEmitter {
private revisionId: number; private revisionId: number;
private maxRevisionId: Map<string, number> = new Map();
private flagResolver: IFlagResolver; private flagResolver: IFlagResolver;
private constructor( private constructor(
@ -51,7 +53,17 @@ export default class ConfigurationRevisionService extends EventEmitter {
return ConfigurationRevisionService.instance; return ConfigurationRevisionService.instance;
} }
async getMaxRevisionId(): Promise<number> { async getMaxRevisionId(environment?: string): Promise<number> {
if (environment && !this.maxRevisionId[environment]) {
await this.updateMaxEnvironmentRevisionId(environment);
}
if (
environment &&
this.maxRevisionId[environment] &&
this.maxRevisionId[environment] > 0
) {
return this.maxRevisionId[environment];
}
if (this.revisionId > 0) { if (this.revisionId > 0) {
return this.revisionId; return this.revisionId;
} else { } else {
@ -59,6 +71,18 @@ export default class ConfigurationRevisionService extends EventEmitter {
} }
} }
async updateMaxEnvironmentRevisionId(environment: string): Promise<number> {
const envRevisionId = await this.eventStore.getMaxRevisionId(
this.maxRevisionId[environment],
environment,
);
if (this.maxRevisionId[environment] ?? 0 < envRevisionId) {
this.maxRevisionId[environment] = envRevisionId;
}
return this.maxRevisionId[environment];
}
async updateMaxRevisionId(emit: boolean = true): Promise<number> { async updateMaxRevisionId(emit: boolean = true): Promise<number> {
if (this.flagResolver.isEnabled('disableUpdateMaxRevisionId')) { if (this.flagResolver.isEnabled('disableUpdateMaxRevisionId')) {
return 0; return 0;
@ -69,8 +93,12 @@ export default class ConfigurationRevisionService extends EventEmitter {
); );
if (this.revisionId !== revisionId) { if (this.revisionId !== revisionId) {
this.logger.debug( this.logger.debug(
'Updating feature configuration with new revision Id', `Updating feature configuration with new revision Id ${revisionId} and all envs: ${Object.keys(this.maxRevisionId).join(', ')}`,
revisionId, );
await Promise.allSettled(
Object.keys(this.maxRevisionId).map((environment) =>
this.updateMaxEnvironmentRevisionId(environment),
),
); );
this.revisionId = revisionId; this.revisionId = revisionId;
if (emit) { if (emit) {
@ -83,5 +111,6 @@ export default class ConfigurationRevisionService extends EventEmitter {
destroy(): void { destroy(): void {
ConfigurationRevisionService.instance?.removeAllListeners(); ConfigurationRevisionService.instance?.removeAllListeners();
ConfigurationRevisionService.instance = undefined;
} }
} }

View File

@ -2538,7 +2538,10 @@ export class FeatureToggleService {
environment, environment,
)); ));
if (!canAddStrategies) { if (!canAddStrategies) {
throw new PermissionError(CREATE_FEATURE_STRATEGY); throw new PermissionError(
CREATE_FEATURE_STRATEGY,
environment,
);
} }
} }
} }

View File

@ -31,7 +31,8 @@ export const TOKEN_TYPE_ERROR_MESSAGE =
export const NO_TOKEN_WHERE_TOKEN_WAS_REQUIRED = export const NO_TOKEN_WHERE_TOKEN_WAS_REQUIRED =
'This endpoint requires an API token. Please add an authorization header to your request with a valid token'; 'This endpoint requires an API token. Please add an authorization header to your request with a valid token';
const apiAccessMiddleware = (
export const apiAccessMiddleware = (
{ {
getLogger, getLogger,
authentication, authentication,

View File

@ -87,6 +87,7 @@ const rbacMiddleware = (
) )
) { ) {
const { featureName } = params; const { featureName } = params;
// TODO track if this deprecated path is still in use
projectId = await featureToggleStore.getProjectId(featureName); projectId = await featureToggleStore.getProjectId(featureName);
} else if ( } else if (
projectId === undefined && projectId === undefined &&

View File

@ -61,7 +61,8 @@ export type IFlagKey =
| 'addConfiguration' | 'addConfiguration'
| 'filterFlagsToArchive' | 'filterFlagsToArchive'
| 'projectListViewToggle' | 'projectListViewToggle'
| 'fetchMode'; | 'fetchMode'
| 'etagByEnv';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;

View File

@ -38,7 +38,10 @@ export interface IEventStore
queryParams: IQueryParam[], queryParams: IQueryParam[],
options?: { withIp?: boolean }, options?: { withIp?: boolean },
): Promise<IEvent[]>; ): Promise<IEvent[]>;
getMaxRevisionId(currentMax?: number): Promise<number>; getMaxRevisionId(
currentMax?: number,
environment?: string,
): Promise<number>;
getRevisionRange(start: number, end: number): Promise<IEvent[]>; getRevisionRange(start: number, end: number): Promise<IEvent[]>;
query(operations: IQueryOperations[]): Promise<IEvent[]>; query(operations: IQueryOperations[]): Promise<IEvent[]>;
queryCount(operations: IQueryOperations[]): Promise<number>; queryCount(operations: IQueryOperations[]): Promise<number>;

View File

@ -1,220 +1,410 @@
import { import {
type IUnleashTest, type IUnleashTest,
setupAppWithCustomConfig, setupAppWithAuth,
} from '../../helpers/test-helper.js'; } from '../../helpers/test-helper.js';
import dbInit, { type ITestDb } from '../../helpers/database-init.js'; import dbInit, { type ITestDb } from '../../helpers/database-init.js';
import getLogger from '../../../fixtures/no-logger.js'; import getLogger from '../../../fixtures/no-logger.js';
import type User from '../../../../lib/types/user.js';
import { TEST_AUDIT_USER } from '../../../../lib/types/index.js';
import { CHANGE_REQUEST_CREATED } from '../../../../lib/events/index.js'; import { CHANGE_REQUEST_CREATED } from '../../../../lib/events/index.js';
// import { DEFAULT_ENV } from '../../../../lib/util/constants'; import { CLIENT, DEFAULT_ENV } from '../../../../lib/server-impl.js';
import { ApiTokenType } from '../../../../lib/types/model.js';
const testUser = { name: 'test', id: -9999 } as User; const validTokens = [
{
tokenName: `client-dev-token`,
permissions: [CLIENT],
projects: ['*'],
environment: 'development',
type: ApiTokenType.CLIENT,
secret: '*:development.client',
},
{
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 prodTokenSecret = validTokens[1].secret;
const allEnvsTokenSecret = validTokens[2].secret;
const shutdownHooks: (() => Promise<void>)[] = []; async function setup({
etagVariant,
etagByEnvEnabled,
}: {
etagVariant: string | undefined;
etagByEnvEnabled: boolean;
}): Promise<{ app: IUnleashTest; db: ITestDb }> {
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,
etagVariant: {
name: etagVariant,
enabled: etagVariant !== undefined,
feature_enabled: etagVariant !== undefined,
},
etagByEnv: etagByEnvEnabled,
},
},
},
db.rawDatabase,
);
return { app, db };
}
async function initialize({ app, db }: { app: IUnleashTest; db: ITestDb }) {
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`,
});
}
async function validateInitialState({
app,
db,
}: { app: IUnleashTest; db: ITestDb }) {
/**
* This helps reason about the etag, which is formed by <query-hash>:<event-id>
* To see the output you need to run this test with --silent=false
* You can see the expected output in the expect statement below
*/
const { events } = await app.services.eventService.getEvents();
// NOTE: events could be processed in different order resulting in a flaky test
const actualEvents = events
.reverse()
.map(({ id, environment, featureName, type }) => ({
id,
environment,
featureName,
type,
}));
let nextId = 8; // this is the first id after the token creation events
const expectedEvents = [
{
id: nextId++,
featureName: 'X',
type: 'feature-created',
},
{
id: nextId++,
featureName: 'Y',
type: 'feature-created',
},
{
id: nextId++,
featureName: 'Y',
type: 'feature-archived',
},
{
id: nextId++,
featureName: 'Z',
type: 'feature-created',
},
{
id: nextId++,
environment: 'development',
featureName: 'Z',
type: 'feature-strategy-add',
},
{
id: nextId++,
environment: 'development',
featureName: 'Z',
type: 'feature-environment-enabled',
},
{
id: nextId++,
environment: 'production',
featureName: 'Z',
type: 'feature-strategy-add',
},
{
id: nextId++,
environment: 'production',
featureName: 'Z',
type: 'feature-environment-enabled',
},
{
id: nextId++,
featureName: 'X',
type: 'change-request-created',
},
];
// We only require that all expectedEvents exist within actualEvents, matching
// only on the properties explicitly specified in each expected object.
// This lets us omit properties (like id) from some expected entries that might
// arrive in different order, without breaking the test.
for (const expectedEvent of expectedEvents) {
expect(actualEvents).toContainEqual(
expect.objectContaining(expectedEvent),
);
}
}
describe.each([ describe.each([
{ {
name: 'disabled', etagVariant: undefined,
enabled: false, etagByEnvEnabled: false,
feature_enabled: false,
}, },
{ {
name: 'v2', etagVariant: 'v2',
enabled: true, etagByEnvEnabled: false,
feature_enabled: true,
}, },
])('feature 304 api client (etag variant = %s)', (etagVariant) => { {
let app: IUnleashTest; etagVariant: 'v2',
let db: ITestDb; etagByEnvEnabled: true,
const apendix = etagVariant.feature_enabled },
? `${etagVariant.name}` ])(
: 'etag_variant_off'; 'feature 304 api client (etag variant = $etagVariant)',
beforeAll(async () => { ({ etagVariant, etagByEnvEnabled }) => {
db = await dbInit(`feature_304_api_client_${apendix}`, getLogger); let app: IUnleashTest;
app = await setupAppWithCustomConfig( let db: ITestDb;
db.stores, const etagVariantEnabled = etagVariant !== undefined;
{ const etagVariantName = etagVariant ?? 'disabled';
experimental: { const expectedDevEventId = etagByEnvEnabled ? 13 : 15;
flags: { beforeAll(async () => {
strictSchemaValidation: true, ({ app, db } = await setup({
etagVariant: etagVariant, etagVariant,
}, etagByEnvEnabled,
}, }));
}, await initialize({ app, db });
db.rawDatabase, await validateInitialState({ app, db });
);
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: `featureX${apendix}`,
description: 'the #1 feature',
impressionData: true,
},
TEST_AUDIT_USER,
);
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: `featureY${apendix}`,
description: 'soon to be the #1 feature',
},
TEST_AUDIT_USER,
);
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: `featureZ${apendix}`,
description: 'terrible feature',
},
TEST_AUDIT_USER,
);
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: `featureArchivedX${apendix}`,
description: 'the #1 feature',
},
TEST_AUDIT_USER,
);
await app.services.featureToggleService.archiveToggle(
`featureArchivedX${apendix}`,
testUser,
TEST_AUDIT_USER,
);
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: `featureArchivedY${apendix}`,
description: 'soon to be the #1 feature',
},
TEST_AUDIT_USER,
);
await app.services.featureToggleService.archiveToggle(
`featureArchivedY${apendix}`,
testUser,
TEST_AUDIT_USER,
);
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: `featureArchivedZ${apendix}`,
description: 'terrible feature',
},
TEST_AUDIT_USER,
);
await app.services.featureToggleService.archiveToggle(
`featureArchivedZ${apendix}`,
testUser,
TEST_AUDIT_USER,
);
await app.services.featureToggleService.createFeatureToggle(
'default',
{
name: `feature.with.variants${apendix}`,
description: 'A feature flag with variants',
},
TEST_AUDIT_USER,
);
await app.services.featureToggleService.saveVariants(
`feature.with.variants${apendix}`,
'default',
[
{
name: 'control',
weight: 50,
weightType: 'fix',
stickiness: 'default',
},
{
name: 'new',
weight: 50,
weightType: 'variable',
stickiness: 'default',
},
],
TEST_AUDIT_USER,
);
await app.services.eventService.storeEvent({
type: CHANGE_REQUEST_CREATED,
createdBy: testUser.email,
createdByUserId: testUser.id,
ip: '127.0.0.1',
featureName: `ch-on-feature-${apendix}`,
}); });
shutdownHooks.push(async () => { afterAll(async () => {
await app.destroy(); await app.destroy();
await db.destroy(); await db.destroy();
}); });
});
test('returns calculated hash', async () => { test('returns calculated hash without if-none-match header (dev env token)', async () => {
const res = await app.request const res = await app.request
.get('/api/client/features') .get('/api/client/features')
.expect('Content-Type', /json/) .set('Authorization', devTokenSecret)
.expect(200); .expect('Content-Type', /json/)
.expect(200);
if (etagVariant.feature_enabled) { if (etagVariantEnabled) {
expect(res.headers.etag).toBe(`"76d8bb0e:16:${etagVariant.name}"`); expect(res.headers.etag).toBe(
expect(res.body.meta.etag).toBe( `"76d8bb0e:${expectedDevEventId}:${etagVariantName}"`,
`"76d8bb0e:16:${etagVariant.name}"`, );
expect(res.body.meta.etag).toBe(
`"76d8bb0e:${expectedDevEventId}:${etagVariantName}"`,
);
} else {
expect(res.headers.etag).toBe(
`"76d8bb0e:${expectedDevEventId}"`,
);
expect(res.body.meta.etag).toBe(
`"76d8bb0e:${expectedDevEventId}"`,
);
}
});
test(`returns ${etagVariantEnabled ? 200 : 304} for pre-calculated hash${etagVariantEnabled ? ' because hash changed' : ''} (dev env token)`, async () => {
const res = await app.request
.get('/api/client/features')
.set('Authorization', devTokenSecret)
.set('if-none-match', `"76d8bb0e:${expectedDevEventId}"`)
.expect(etagVariantEnabled ? 200 : 304);
if (etagVariantEnabled) {
expect(res.headers.etag).toBe(
`"76d8bb0e:${expectedDevEventId}:${etagVariantName}"`,
);
expect(res.body.meta.etag).toBe(
`"76d8bb0e:${expectedDevEventId}:${etagVariantName}"`,
);
}
});
test('creating a new feature does not modify etag', async () => {
await app.createFeature('new');
await app.services.configurationRevisionService.updateMaxRevisionId();
await app.request
.get('/api/client/features')
.set('Authorization', devTokenSecret)
.set(
'if-none-match',
`"76d8bb0e:${expectedDevEventId}${etagVariantEnabled ? `:${etagVariantName}` : ''}"`,
)
.expect(304);
});
test('a token with all envs should get the max id regardless of the environment', async () => {
const currentProdEtag = `"67e24428:15${etagVariantEnabled ? `:${etagVariantName}` : ''}"`;
const { headers } = await app.request
.get('/api/client/features')
.set('if-none-match', currentProdEtag)
.set('Authorization', allEnvsTokenSecret)
.expect(200);
// it's a different hash than prod, but gets the max id
expect(headers.etag).toEqual(
`"ae443048:15${etagVariantEnabled ? `:${etagVariantName}` : ''}"`,
); );
} else { });
expect(res.headers.etag).toBe('"76d8bb0e:16"');
expect(res.body.meta.etag).toBe('"76d8bb0e:16"');
}
});
test(`returns ${etagVariant.feature_enabled ? 200 : 304} for pre-calculated hash${etagVariant.feature_enabled ? ' because hash changed' : ''}`, async () => { test.runIf(!etagByEnvEnabled)(
const res = await app.request 'production environment gets same event id in etag than development',
.get('/api/client/features') async () => {
.set('if-none-match', '"76d8bb0e:16"') const { headers: prodHeaders } = await app.request
.expect(etagVariant.feature_enabled ? 200 : 304); .get('/api/client/features?bla=1')
.set('Authorization', prodTokenSecret)
.expect(200);
if (etagVariant.feature_enabled) { expect(prodHeaders.etag).toEqual(
expect(res.headers.etag).toBe(`"76d8bb0e:16:${etagVariant.name}"`); `"67e24428:15${etagVariantEnabled ? `:${etagVariantName}` : ''}"`,
expect(res.body.meta.etag).toBe( );
`"76d8bb0e:16:${etagVariant.name}"`,
);
}
});
test('returns 200 when content updates and hash does not match anymore', async () => { const { headers: devHeaders } = await app.request
await app.services.featureToggleService.createFeatureToggle( .get('/api/client/features')
'default', .set('Authorization', devTokenSecret)
{ .expect(200);
name: `featureNew304${apendix}`,
description: 'the #1 feature', expect(devHeaders.etag).toEqual(
`"76d8bb0e:15${etagVariantEnabled ? `:${etagVariantName}` : ''}"`,
);
}, },
TEST_AUDIT_USER,
); );
await app.services.configurationRevisionService.updateMaxRevisionId();
const res = await app.request test.runIf(!etagByEnvEnabled)(
.get('/api/client/features') 'modifying dev environment also invalidates prod tokens',
.set('if-none-match', 'ae443048:16') async () => {
.expect(200); const currentDevEtag = `"76d8bb0e:${expectedDevEventId}${etagVariantEnabled ? `:${etagVariantName}` : ''}"`;
const currentProdEtag = `"67e24428:15${etagVariantEnabled ? `:${etagVariantName}` : ''}"`;
await app.request
.get('/api/client/features')
.set('if-none-match', currentProdEtag)
.set('Authorization', prodTokenSecret)
.expect(304);
if (etagVariant.feature_enabled) { await app.request
expect(res.headers.etag).toBe(`"76d8bb0e:16:${etagVariant.name}"`); .get('/api/client/features')
expect(res.body.meta.etag).toBe( .set('Authorization', devTokenSecret)
`"76d8bb0e:16:${etagVariant.name}"`, .set('if-none-match', currentDevEtag)
); .expect(304);
} else {
expect(res.headers.etag).toBe('"76d8bb0e:16"');
expect(res.body.meta.etag).toBe('"76d8bb0e:16"');
}
});
});
// running after all inside describe block, causes some of the queries to fail to acquire a connection await app.enableFeature('X', DEFAULT_ENV);
// this workaround is to run the afterAll outside the describe block await app.services.configurationRevisionService.updateMaxRevisionId();
afterAll(async () => {
await Promise.all(shutdownHooks.map((hook) => hook())); await app.request
}); .get('/api/client/features')
.set('Authorization', prodTokenSecret)
.set('if-none-match', currentProdEtag)
.expect(200);
const { headers: devHeaders } = await app.request
.get('/api/client/features')
.set('Authorization', devTokenSecret)
.set('if-none-match', currentDevEtag)
.expect(200);
// Note: this test yields a different result if run in isolation
// this is because the id 19 depends on a previous test adding a feature
// otherwise the id will be 18
expect(devHeaders.etag).toEqual(
`"76d8bb0e:19${etagVariantEnabled ? `:${etagVariantName}` : ''}"`,
);
},
);
test.runIf(etagByEnvEnabled)(
'production environment gets a different etag than development',
async () => {
const { headers: prodHeaders } = await app.request
.get('/api/client/features?bla=1')
.set('Authorization', prodTokenSecret)
.expect(200);
expect(prodHeaders.etag).toEqual(
`"67e24428:15${etagVariantEnabled ? `:${etagVariantName}` : ''}"`,
);
const { headers: devHeaders } = await app.request
.get('/api/client/features')
.set('Authorization', devTokenSecret)
.expect(200);
expect(devHeaders.etag).toEqual(
`"76d8bb0e:13${etagVariantEnabled ? `:${etagVariantName}` : ''}"`,
);
},
);
test.runIf(etagByEnvEnabled)(
'modifying dev environment should only invalidate dev tokens',
async () => {
const currentDevEtag = `"76d8bb0e:13${etagVariantEnabled ? `:${etagVariantName}` : ''}"`;
const currentProdEtag = `"67e24428:15${etagVariantEnabled ? `:${etagVariantName}` : ''}"`;
await app.request
.get('/api/client/features')
.set('if-none-match', currentProdEtag)
.set('Authorization', prodTokenSecret)
.expect(304);
await app.request
.get('/api/client/features')
.set('Authorization', devTokenSecret)
.set('if-none-match', currentDevEtag)
.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', currentProdEtag)
.expect(304);
const { headers: devHeaders } = await app.request
.get('/api/client/features')
.set('Authorization', devTokenSecret)
.set('if-none-match', currentDevEtag)
.expect(200);
// Note: this test yields a different result if run in isolation
// this is because the id 19 depends on a previous test adding a feature
// otherwise the id will be 18
expect(devHeaders.etag).toEqual(
`"76d8bb0e:19${etagVariantEnabled ? `:${etagVariantName}` : ''}"`,
);
},
);
},
);