wip: doctor/cli/docs/api to vector db consolidation; api hardening for descriptions, tenant, and scopes; migrations and conversions of all DALs to EF v10
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
type StubAuthSession = {
|
||||
subjectId: string;
|
||||
tenant: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
export type CapturedApiRequest = {
|
||||
url: string;
|
||||
tenantId: string | null;
|
||||
};
|
||||
|
||||
const mockConfig = {
|
||||
authority: {
|
||||
issuer: 'http://127.0.0.1:4400/authority',
|
||||
clientId: 'stella-ops-ui',
|
||||
authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize',
|
||||
tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token',
|
||||
logoutEndpoint: 'http://127.0.0.1:4400/authority/connect/logout',
|
||||
redirectUri: 'http://127.0.0.1:4400/auth/callback',
|
||||
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
|
||||
scope:
|
||||
'openid profile email ui.read ui.admin authority:tenants.read authority:clients.read authority:clients.write findings:read orch:read orch:operate advisory:read vex:read exceptions:read exceptions:approve aoc:verify scanner:read policy:read policy:author policy:review policy:approve policy:simulate policy:audit release:read release:write release:publish sbom:read',
|
||||
audience: 'http://127.0.0.1:4400/gateway',
|
||||
dpopAlgorithms: ['ES256'],
|
||||
refreshLeewaySeconds: 60,
|
||||
},
|
||||
apiBaseUrls: {
|
||||
authority: '/authority',
|
||||
scanner: '/scanner',
|
||||
policy: '/policy',
|
||||
concelier: '/concelier',
|
||||
attestor: '/attestor',
|
||||
gateway: '/gateway',
|
||||
},
|
||||
quickstartMode: true,
|
||||
setup: 'complete',
|
||||
};
|
||||
|
||||
const oidcConfig = {
|
||||
issuer: mockConfig.authority.issuer,
|
||||
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
|
||||
token_endpoint: mockConfig.authority.tokenEndpoint,
|
||||
jwks_uri: 'http://127.0.0.1:4400/authority/.well-known/jwks.json',
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
};
|
||||
|
||||
const tenantCatalog = [
|
||||
{
|
||||
id: 'tenant-alpha',
|
||||
displayName: 'Tenant Alpha',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['admin'],
|
||||
},
|
||||
{
|
||||
id: 'tenant-bravo',
|
||||
displayName: 'Tenant Bravo',
|
||||
status: 'active',
|
||||
isolationMode: 'shared',
|
||||
defaultRoles: ['admin'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type MultiTenantFixture = {
|
||||
capturedApiRequests: CapturedApiRequest[];
|
||||
clearCapturedApiRequests: () => void;
|
||||
};
|
||||
|
||||
export async function installMultiTenantSessionFixture(page: Page): Promise<MultiTenantFixture> {
|
||||
const capturedApiRequests: CapturedApiRequest[] = [];
|
||||
let selectedTenant = 'tenant-alpha';
|
||||
|
||||
const adminSession: StubAuthSession = {
|
||||
subjectId: 'e2e-tenant-admin',
|
||||
tenant: selectedTenant,
|
||||
scopes: [
|
||||
'ui.read',
|
||||
'ui.admin',
|
||||
'authority:tenants.read',
|
||||
'authority:clients.read',
|
||||
'authority:clients.write',
|
||||
'findings:read',
|
||||
'orch:read',
|
||||
'orch:operate',
|
||||
'advisory:read',
|
||||
'vex:read',
|
||||
'exceptions:read',
|
||||
'exceptions:approve',
|
||||
'aoc:verify',
|
||||
'scanner:read',
|
||||
'policy:read',
|
||||
'policy:author',
|
||||
'policy:review',
|
||||
'policy:approve',
|
||||
'policy:simulate',
|
||||
'policy:audit',
|
||||
'release:read',
|
||||
'release:write',
|
||||
'release:publish',
|
||||
'sbom:read',
|
||||
'admin',
|
||||
],
|
||||
};
|
||||
|
||||
await page.addInitScript((session: StubAuthSession) => {
|
||||
let seededTenant = session.tenant;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem('stellaops.auth.session.full');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as { tenantId?: string | null };
|
||||
if (typeof parsed.tenantId === 'string' && parsed.tenantId.trim().length > 0) {
|
||||
seededTenant = parsed.tenantId.trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed persisted session values
|
||||
}
|
||||
|
||||
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = {
|
||||
...session,
|
||||
tenant: seededTenant,
|
||||
};
|
||||
}, adminSession);
|
||||
|
||||
await page.route('**/platform/envsettings.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('**/config.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockConfig),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('**/authority/.well-known/openid-configuration', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(oidcConfig),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('**/.well-known/openid-configuration', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(oidcConfig),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('**/authority/.well-known/jwks.json', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ keys: [] }),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('**/authority/connect/**', (route) =>
|
||||
route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'not-used-in-e2e-fixture' }),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.route('**/console/tenants**', (route) => {
|
||||
const requestedTenant = resolveTenantFromRequestHeaders(route.request().headers()) ?? selectedTenant;
|
||||
if (tenantCatalog.some((tenant) => tenant.id === requestedTenant)) {
|
||||
selectedTenant = requestedTenant;
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
tenants: tenantCatalog,
|
||||
selectedTenant,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/console/profile**', (route) => {
|
||||
const tenant = resolveTenantFromRequestHeaders(route.request().headers()) ?? selectedTenant;
|
||||
selectedTenant = tenant;
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
subjectId: adminSession.subjectId,
|
||||
username: 'tenant-admin',
|
||||
displayName: 'Tenant Admin',
|
||||
tenant,
|
||||
roles: ['admin'],
|
||||
scopes: adminSession.scopes,
|
||||
audiences: ['stellaops'],
|
||||
authenticationMethods: ['pwd'],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/console/token/introspect**', (route) => {
|
||||
const tenant = resolveTenantFromRequestHeaders(route.request().headers()) ?? selectedTenant;
|
||||
selectedTenant = tenant;
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
active: true,
|
||||
tenant,
|
||||
subject: adminSession.subjectId,
|
||||
clientId: 'stellaops-console',
|
||||
scopes: adminSession.scopes,
|
||||
audiences: ['stellaops'],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route('**/api/**', (route) => {
|
||||
const tenantId = resolveTenantFromRequestHeaders(route.request().headers());
|
||||
capturedApiRequests.push({
|
||||
url: route.request().url(),
|
||||
tenantId,
|
||||
});
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
tenantId: tenantId ?? selectedTenant,
|
||||
items: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
capturedApiRequests,
|
||||
clearCapturedApiRequests: () => {
|
||||
capturedApiRequests.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTenantFromRequestHeaders(headers: Record<string, string>): string | null {
|
||||
const headerCandidates = [
|
||||
headers['x-stellaops-tenant'],
|
||||
headers['x-stella-tenant'],
|
||||
headers['x-tenant-id'],
|
||||
];
|
||||
|
||||
for (const candidate of headerCandidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type TenantPageMatrixEntry = {
|
||||
section: string;
|
||||
route: string;
|
||||
expectedBreadcrumb: string;
|
||||
};
|
||||
|
||||
export const tenantSwitchPageMatrix: readonly TenantPageMatrixEntry[] = [
|
||||
{
|
||||
section: 'Mission Control',
|
||||
route: '/mission-control/board',
|
||||
expectedBreadcrumb: 'Mission Board',
|
||||
},
|
||||
{
|
||||
section: 'Releases',
|
||||
route: '/releases/overview',
|
||||
expectedBreadcrumb: 'Release Overview',
|
||||
},
|
||||
{
|
||||
section: 'Security',
|
||||
route: '/security/posture',
|
||||
expectedBreadcrumb: 'Security',
|
||||
},
|
||||
{
|
||||
section: 'Security',
|
||||
route: '/security/unknowns',
|
||||
expectedBreadcrumb: 'Unknowns',
|
||||
},
|
||||
{
|
||||
section: 'Evidence',
|
||||
route: '/evidence/overview',
|
||||
expectedBreadcrumb: 'Evidence',
|
||||
},
|
||||
{
|
||||
section: 'Ops',
|
||||
route: '/ops/operations',
|
||||
expectedBreadcrumb: 'Operations',
|
||||
},
|
||||
{
|
||||
section: 'Setup',
|
||||
route: '/setup/topology/overview',
|
||||
expectedBreadcrumb: 'Topology',
|
||||
},
|
||||
{
|
||||
section: 'Admin',
|
||||
route: '/setup/identity-access',
|
||||
expectedBreadcrumb: 'Identity & Access',
|
||||
},
|
||||
];
|
||||
173
src/Web/StellaOps.Web/tests/e2e/tenant-switch-matrix.spec.ts
Normal file
173
src/Web/StellaOps.Web/tests/e2e/tenant-switch-matrix.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
import { type CapturedApiRequest, installMultiTenantSessionFixture } from './support/multi-tenant-session.fixture';
|
||||
import { tenantSwitchPageMatrix } from './support/tenant-switch-page-matrix';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.describe('Multi-tenant switch matrix', () => {
|
||||
test('switches tenant from header and persists across primary sections (desktop)', async ({ page }) => {
|
||||
const fixture = await installMultiTenantSessionFixture(page);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await go(page, '/mission-control/board');
|
||||
await expectTenantLabelContains(page, 'alpha');
|
||||
|
||||
await switchTenant(page, 'Tenant Bravo');
|
||||
await expectTenantLabelContains(page, 'bravo');
|
||||
|
||||
fixture.clearCapturedApiRequests();
|
||||
|
||||
for (const entry of tenantSwitchPageMatrix) {
|
||||
await navigateInApp(page, entry.route);
|
||||
await expect(page.locator('main')).toBeVisible();
|
||||
await expectTenantLabelContains(page, 'bravo');
|
||||
}
|
||||
|
||||
const tenantScopedRequests = fixture.capturedApiRequests.filter((request) => {
|
||||
const url = request.url.toLowerCase();
|
||||
return !url.includes('/api/auth/') &&
|
||||
!url.includes('/health') &&
|
||||
!url.includes('/ready') &&
|
||||
!url.includes('/metrics');
|
||||
});
|
||||
|
||||
expect(tenantScopedRequests.length).toBeGreaterThan(0);
|
||||
for (const request of tenantScopedRequests) {
|
||||
expect(request.tenantId).toBe('tenant-bravo');
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps tenant selector usable and persistent on mobile viewport', async ({ page }) => {
|
||||
await installMultiTenantSessionFixture(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
await go(page, '/mission-control/board');
|
||||
await expect(page.locator('.topbar__tenant-btn')).toBeVisible();
|
||||
await switchTenant(page, 'Tenant Bravo');
|
||||
await expectTenantLabelContains(page, 'bravo');
|
||||
|
||||
await navigateInApp(page, '/setup/identity-access');
|
||||
await expectTenantLabelContains(page, 'bravo');
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
|
||||
await expectTenantLabelContains(page, 'bravo');
|
||||
});
|
||||
|
||||
for (const entry of tenantSwitchPageMatrix) {
|
||||
test(`applies selected tenant on ${entry.section} route`, async ({ page }) => {
|
||||
const fixture = await installMultiTenantSessionFixture(page);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await go(page, '/mission-control/board');
|
||||
await switchTenant(page, 'Tenant Bravo');
|
||||
await expectTenantLabelContains(page, 'bravo');
|
||||
|
||||
fixture.clearCapturedApiRequests();
|
||||
|
||||
await navigateInApp(page, entry.route);
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(entry.route);
|
||||
const routeHints = buildRouteHints(entry.section, entry.expectedBreadcrumb);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const mainText = (await page.locator('main').innerText()).toLowerCase();
|
||||
return routeHints.some((hint) => mainText.includes(hint.toLowerCase()));
|
||||
})
|
||||
.toBe(true);
|
||||
await expectTenantLabelContains(page, 'bravo');
|
||||
|
||||
const tenantScopedRequests = toTenantScopedRequests(fixture.capturedApiRequests);
|
||||
const crossTenantRequests = tenantScopedRequests.filter((request) => request.tenantId && request.tenantId !== 'tenant-bravo');
|
||||
expect(crossTenantRequests.length).toBe(0);
|
||||
for (const request of tenantScopedRequests) {
|
||||
expect(request.tenantId).toBe('tenant-bravo');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('switches tenant in both directions without stale request headers', async ({ page }) => {
|
||||
const fixture = await installMultiTenantSessionFixture(page);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await go(page, '/mission-control/board');
|
||||
|
||||
await switchTenant(page, 'Tenant Bravo');
|
||||
await expectTenantLabelContains(page, 'bravo');
|
||||
|
||||
fixture.clearCapturedApiRequests();
|
||||
await navigateInApp(page, '/setup/topology/overview');
|
||||
const bravoRequests = toTenantScopedRequests(fixture.capturedApiRequests);
|
||||
const crossTenantBravoRequests = bravoRequests.filter((request) => request.tenantId && request.tenantId !== 'tenant-bravo');
|
||||
expect(crossTenantBravoRequests.length).toBe(0);
|
||||
for (const request of bravoRequests) {
|
||||
expect(request.tenantId).toBe('tenant-bravo');
|
||||
}
|
||||
|
||||
await switchTenant(page, 'Tenant Alpha');
|
||||
await expectTenantLabelContains(page, 'alpha');
|
||||
|
||||
fixture.clearCapturedApiRequests();
|
||||
await navigateInApp(page, '/security/posture');
|
||||
const alphaRequests = toTenantScopedRequests(fixture.capturedApiRequests);
|
||||
const crossTenantAlphaRequests = alphaRequests.filter((request) => request.tenantId && request.tenantId !== 'tenant-alpha');
|
||||
expect(crossTenantAlphaRequests.length).toBe(0);
|
||||
for (const request of alphaRequests) {
|
||||
expect(request.tenantId).toBe('tenant-alpha');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function toTenantScopedRequests(capturedApiRequests: readonly CapturedApiRequest[]): CapturedApiRequest[] {
|
||||
return capturedApiRequests.filter((request) => {
|
||||
const url = request.url.toLowerCase();
|
||||
return !url.includes('/api/auth/') &&
|
||||
!url.includes('/health') &&
|
||||
!url.includes('/ready') &&
|
||||
!url.includes('/metrics');
|
||||
});
|
||||
}
|
||||
|
||||
function buildRouteHints(section: string, expectedBreadcrumb: string): readonly string[] {
|
||||
const hints = new Set<string>();
|
||||
const sectionHint = section.trim();
|
||||
const singularSectionHint = sectionHint.endsWith('s') ? sectionHint.slice(0, -1) : sectionHint;
|
||||
hints.add(expectedBreadcrumb.trim());
|
||||
hints.add(sectionHint);
|
||||
hints.add(singularSectionHint);
|
||||
return [...hints].filter((hint) => hint.length > 0);
|
||||
}
|
||||
|
||||
async function switchTenant(page: Page, displayName: string): Promise<void> {
|
||||
const trigger = page.locator('.topbar__tenant-btn');
|
||||
await trigger.click();
|
||||
|
||||
const option = page.locator('.topbar__tenant-option-name', {
|
||||
hasText: displayName,
|
||||
});
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
|
||||
}
|
||||
|
||||
async function go(page: Page, path: string): Promise<void> {
|
||||
await page.goto(path, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
|
||||
}
|
||||
|
||||
async function navigateInApp(page: Page, path: string): Promise<void> {
|
||||
await page.evaluate((nextPath) => {
|
||||
window.history.pushState({}, '', nextPath);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, path);
|
||||
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe(path);
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
|
||||
}
|
||||
|
||||
async function expectTenantLabelContains(page: Page, tenantHint: string): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await page.locator('.topbar__tenant-btn').innerText()).toLowerCase())
|
||||
.toContain(tenantHint.toLowerCase());
|
||||
}
|
||||
Reference in New Issue
Block a user