diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 257ee81a..63776688 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -1619,7 +1619,6 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error { var consents []domain.ClientConsentWithTenantInfo var total int64 - var err error if subject != "" { // Resolve subject if it's email/name (Legacy support) diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts index e1d16054..d0ba28d0 100644 --- a/devfront/playwright.config.ts +++ b/devfront/playwright.config.ts @@ -3,6 +3,11 @@ import { defineConfig, devices } from "@playwright/test"; const configuredWorkers = process.env.PLAYWRIGHT_WORKERS ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) : undefined; +const skipWebServer = + process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" || + process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true"; +const baseURL = + process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5174"; /** * Read environment variables from file. @@ -30,7 +35,7 @@ export default defineConfig({ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: "http://localhost:5174", + baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", @@ -55,11 +60,13 @@ export default defineConfig({ ], /* Run your local dev server before starting the tests */ - webServer: { - command: process.env.CI - ? "npm run build && npm run preview -- --port 5174" - : "npm run dev -- --port 5174", - url: "http://localhost:5174", - reuseExistingServer: !process.env.CI, - }, + webServer: skipWebServer + ? undefined + : { + command: process.env.CI + ? "npm run build && npm run preview -- --port 5174" + : "npm run dev -- --port 5174", + url: baseURL, + reuseExistingServer: !process.env.CI, + }, }); diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts index b304583e..09b492f8 100644 --- a/devfront/tests/clients.spec.ts +++ b/devfront/tests/clients.spec.ts @@ -5,6 +5,13 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; + +test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } +}); test("clients page loads correctly", async ({ page }) => { await seedAuth(page); diff --git a/devfront/tests/devfront-audit.spec.ts b/devfront/tests/devfront-audit.spec.ts index 8a0890d2..9158fa4e 100644 --- a/devfront/tests/devfront-audit.spec.ts +++ b/devfront/tests/devfront-audit.spec.ts @@ -6,10 +6,17 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i; test.describe("DevFront audit logs", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + test.beforeEach(async ({ page }) => { page.on("dialog", async (dialog) => { await dialog.accept().catch(() => {}); diff --git a/devfront/tests/devfront-clients-lifecycle.spec.ts b/devfront/tests/devfront-clients-lifecycle.spec.ts index a048eaa7..9a66341b 100644 --- a/devfront/tests/devfront-clients-lifecycle.spec.ts +++ b/devfront/tests/devfront-clients-lifecycle.spec.ts @@ -6,11 +6,18 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i; const jwksUri = "https://rp.example.com/.well-known/jwks.json"; test.describe("DevFront clients lifecycle", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + test.beforeEach(async ({ page }) => { page.on("dialog", async (dialog) => { await dialog.accept(); diff --git a/devfront/tests/devfront-consents.spec.ts b/devfront/tests/devfront-consents.spec.ts index 27518c3a..abcc4db6 100644 --- a/devfront/tests/devfront-consents.spec.ts +++ b/devfront/tests/devfront-consents.spec.ts @@ -5,8 +5,15 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; test.describe("DevFront consents", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + test.beforeEach(async ({ page }) => { page.on("dialog", async (dialog) => { await dialog.accept(); diff --git a/devfront/tests/devfront-relationships.spec.ts b/devfront/tests/devfront-relationships.spec.ts new file mode 100644 index 00000000..b88fb3a3 --- /dev/null +++ b/devfront/tests/devfront-relationships.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from "@playwright/test"; +import { + type ClientRelation, + type Consent, + installDevApiMock, + makeClient, + seedAuth, +} from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; + +test.describe("DevFront relationships", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + + test.beforeEach(async ({ page }) => { + page.on("dialog", async (dialog) => { + await dialog.accept(); + }); + await seedAuth(page, "rp_admin"); + }); + + test("list add and remove direct RP relationships", async ({ page }) => { + const state = { + clients: [makeClient("client-rel", { name: "Relations app" })], + consents: [] as Consent[], + relations: { + "client-rel": [ + { + relation: "config_editor", + subject: "User:user-1", + subjectType: "User", + subjectId: "user-1", + }, + ] satisfies ClientRelation[], + }, + auditLogsByCursor: undefined, + }; + await installDevApiMock(page, state); + + await page.goto("/clients/client-rel/relationships"); + await expect(page.getByText("Client Relationships")).toBeVisible(); + await expect(page.getByText("User:user-1")).toBeVisible(); + + await page.getByLabel(/Relation/i).selectOption("secret_rotator"); + await page.getByLabel(/User ID/i).fill("user-2"); + await page.getByRole("button", { name: /^Add$/i }).click(); + + await expect(page.getByText("User:user-2")).toBeVisible(); + await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(2); + + await page + .locator("tr") + .filter({ hasText: "User:user-2" }) + .getByRole("button", { name: /Delete|삭제/i }) + .click(); + + await expect(page.getByText("User:user-2")).toHaveCount(0); + await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(1); + }); +}); diff --git a/devfront/tests/devfront-security.spec.ts b/devfront/tests/devfront-security.spec.ts index 5ac03361..0e451a59 100644 --- a/devfront/tests/devfront-security.spec.ts +++ b/devfront/tests/devfront-security.spec.ts @@ -5,8 +5,15 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; test.describe("DevFront security and isolation", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + test.beforeEach(async ({ page }) => { page.on("dialog", async (dialog) => { await dialog.accept(); diff --git a/devfront/tests/devfront-tenant-switch.spec.ts b/devfront/tests/devfront-tenant-switch.spec.ts index 7471b1f7..8591b9fb 100644 --- a/devfront/tests/devfront-tenant-switch.spec.ts +++ b/devfront/tests/devfront-tenant-switch.spec.ts @@ -4,8 +4,15 @@ import { makeClient, seedAuth, } from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; test.describe("DevFront tenant switch", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + const MOCK_STATE = { clients: [makeClient("client-a", { name: "Tenant A App" })], consents: [], diff --git a/devfront/tests/example.spec.ts b/devfront/tests/example.spec.ts index 53b304ae..565aa49a 100644 --- a/devfront/tests/example.spec.ts +++ b/devfront/tests/example.spec.ts @@ -1,4 +1,11 @@ import { expect, test } from "@playwright/test"; +import { captureEvidence } from "./helpers/evidence"; + +test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } +}); test("has title", async ({ page }) => { await page.goto("/"); diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index d914f364..a375f4f6 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -53,6 +53,13 @@ export type Consent = { tenantName: string; }; +export type ClientRelation = { + relation: string; + subject: string; + subjectType: string; + subjectId: string; +}; + export type AuditLog = { event_id: string; timestamp: string; @@ -67,6 +74,7 @@ export type AuditLog = { export type DevApiMockState = { clients: Client[]; consents: Consent[]; + relations?: Record; auditLogsByCursor?: Record< string, { items: AuditLog[]; next_cursor?: string } @@ -292,6 +300,68 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { }); } + if ( + pathname.startsWith("/api/v1/dev/clients/") && + pathname.endsWith("/relations") && + method === "GET" + ) { + const clientId = pathname.split("/")[5] ?? ""; + return json(route, { + items: state.relations?.[clientId] ?? [], + }); + } + + if ( + pathname.startsWith("/api/v1/dev/clients/") && + pathname.endsWith("/relations") && + method === "POST" + ) { + const clientId = pathname.split("/")[5] ?? ""; + const payload = (request.postDataJSON() as { + relation?: string; + subject?: string; + userId?: string; + }) || { relation: "config_editor" }; + const subject = + payload.subject || + (payload.userId ? `User:${payload.userId}` : "User:playwright-user"); + const subjectId = subject.startsWith("User:") + ? subject.slice("User:".length) + : subject; + const created: ClientRelation = { + relation: payload.relation ?? "config_editor", + subject, + subjectType: "User", + subjectId, + }; + if (!state.relations) { + state.relations = {}; + } + if (!state.relations[clientId]) { + state.relations[clientId] = []; + } + state.relations[clientId].push(created); + appendAuditLog("CLIENT_RELATION_CREATE", "ADD_RELATION", clientId); + return json(route, created, 201); + } + + if ( + pathname.startsWith("/api/v1/dev/clients/") && + pathname.endsWith("/relations") && + method === "DELETE" + ) { + const clientId = pathname.split("/")[5] ?? ""; + const relation = searchParams.get("relation") || ""; + const subject = searchParams.get("subject") || ""; + if (state.relations?.[clientId]) { + state.relations[clientId] = state.relations[clientId].filter( + (item) => !(item.relation === relation && item.subject === subject), + ); + } + appendAuditLog("CLIENT_RELATION_DELETE", "REMOVE_RELATION", clientId); + return route.fulfill({ status: 204 }); + } + if ( pathname.startsWith("/api/v1/dev/clients/") && pathname.endsWith("/status") && diff --git a/docker/ory/oathkeeper/rules.active.json b/docker/ory/oathkeeper/rules.active.json index fd6bfb2d..4a0735da 100755 --- a/docker/ory/oathkeeper/rules.active.json +++ b/docker/ory/oathkeeper/rules.active.json @@ -156,4 +156,4 @@ "authorizer": { "handler": "allow" }, "mutators": [{ "handler": "noop" }] } -] +] \ No newline at end of file