Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/mock-api-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"dependencies": {
"@effect/language-service": "catalog:effect",
"@effect/opentelemetry": "0.53.1",
"@effect/opentelemetry": "catalog:effect",
"@effect/platform": "catalog:effect",
"@effect/platform-node": "catalog:effect",
"@opentelemetry/sdk-logs": "0.202.0",
Expand Down
66 changes: 66 additions & 0 deletions e2e/mock-api-v2/src/addStepCookie.openapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/

export const addStepCookie = (spec: any) => {
const FLOW_TAGS = new Set(['Authorization', 'Capabilities']);
const FLOW_PATH_MATCHERS = [
/\/davinci\/authorize\b/,
/\/davinci\/connections\/[^/]+\/capabilities\//,
];

const shouldAnnotate = (path: string, op: any) =>
(Array.isArray(op?.tags) && op.tags.some((t: string) => FLOW_TAGS.has(t))) ||
FLOW_PATH_MATCHERS.some((rx) => rx.test(path));

const addCookieParam = (op: any) => {
op.parameters ||= [];
const already = op.parameters.some(
(p: any) => p && p.in === 'cookie' && p.name === 'stepIndex',
);
if (!already) {
op.parameters.push({
name: 'stepIndex',
in: 'cookie',
required: false,
description:
'Current flow step. Server initializes on first request and increments thereafter.',
schema: { type: 'integer', minimum: 0 },
example: 2,
});
}
};

const ensureSetCookie = (op: any, status: string) => {
op.responses ||= {};
const resp = (op.responses[status] ||= { description: 'Success' });
resp.headers ||= {};
if (!resp.headers['Set-Cookie']) {
resp.headers['Set-Cookie'] = {
description:
'Updated step cookie (e.g., `stepIndex=3; Path=/; HttpOnly; Secure; SameSite=Lax`). ' +
'May be removed on completion.',
schema: { type: 'string' },
example: 'stepIndex=3; Path=/; HttpOnly; Secure; SameSite=Lax',
};
}
};

for (const [path, methods] of Object.entries(spec.paths ?? {})) {
for (const [, op] of Object.entries<any>(methods as any)) {
if (!op || typeof op !== 'object') continue;
if (!shouldAnnotate(path, op)) continue;
addCookieParam(op);
// Add for common success statuses you use
ensureSetCookie(op, '200');
ensureSetCookie(op, '302');
}
}

return spec;
};

export default addStepCookie;
5 changes: 1 addition & 4 deletions e2e/mock-api-v2/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
class InvalidUsernamePassword {
readonly _tag = 'InvalidUsernamePassword';
}

class FetchError {
readonly _tag = 'FetchError';
Expand All @@ -20,4 +17,4 @@ class UnableToFindNextStep {
readonly _tag = 'UnableToFindNextStep';
}

export { FetchError, InvalidUsernamePassword, InvalidProtectNode, UnableToFindNextStep };
export { FetchError, InvalidProtectNode, UnableToFindNextStep };
20 changes: 14 additions & 6 deletions e2e/mock-api-v2/src/handlers/authorize.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,26 @@
*/
import { Effect } from 'effect';

import { Authorize } from '../services/authorize.service.js';
import { MockApi } from '../spec.js';
import { HttpApiBuilder } from '@effect/platform';
import { HttpApiBuilder, HttpApiError } from '@effect/platform';
import { getFirstElementAndRespond } from '../services/mock-env-helpers/index.js';

const AuthorizeHandlerMock = HttpApiBuilder.group(MockApi, 'Authorization', (handlers) =>
handlers.handle('DavinciAuthorize', ({ urlParams }) =>
handlers.handle('authorize', ({ urlParams }) =>
Effect.gen(function* () {
const { handleAuthorize } = yield* Authorize;
/**
* We expect an acr_value query parameter to be present in the request.
* If it is not present, we return a 404 Not Found error.
*/
const acr_value = urlParams?.acr_values ?? '';

const response = yield* handleAuthorize(urlParams);
if (!acr_value) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we requiring the use of acr_values?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because i have no way of directing the code to a given flow, unless I provide a default.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could have a fallback, but I see what you're saying. We can address this later.

return yield* Effect.fail(new HttpApiError.NotFound());
}

return response.body;
const response = yield* getFirstElementAndRespond(urlParams);

return response;
}).pipe(Effect.withSpan('DavinciAuthorize')),
),
);
Expand Down
148 changes: 148 additions & 0 deletions e2e/mock-api-v2/src/handlers/capabilities.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
*/
import { Console, Effect, pipe } from 'effect';
import { MockApi } from '../spec.js';
import {
HttpApiBuilder,
HttpApiError,
HttpBody,
HttpServerRequest,
HttpServerResponse,
} from '@effect/platform';
import { responseMap } from '../responses/index.js';
import { validator } from '../helpers/match.js';
import { returnSuccessResponseRedirect } from '../responses/return-success-redirect.js';

const CapabilitiesHandlerMock = HttpApiBuilder.group(MockApi, 'Capabilities', (handlers) =>
handlers.handle('capabilities', ({ urlParams, payload }) =>
Effect.gen(function* () {
/**
* We expect an acr_value query parameter to be present in the request.
* If it is not present, we return a 404 Not Found error.
*/
const acr_value = urlParams?.acr_values ?? '';
console.log('acr_value', acr_value);

if (!acr_value) {
return yield* Effect.fail(new HttpApiError.NotFound());
}
Comment on lines +27 to +32
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are ACR Values used within this route? I thought ACR was an /authorize.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to know what flow to pull from, unless we keep the state in another cookie, i have no way of mapping the current request to the responseMap.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a cookie is a good idea, but let's not worry about it right now.


/**
* We need a step index cookie to determine which step of the authentication process we are on.
* If the cookie is not present, we return a 404 Not Found error.
*/

const req = yield* HttpServerRequest.HttpServerRequest;

const stepIndexCookie = req.cookies['stepIndex'];
console.log(req.cookies);

/**
* If we are here with no step index that means we can't continue through a flow.
* We should error
*/
if (!stepIndexCookie) {
console.log('no step index');
return yield* Effect.fail(new HttpApiError.NotFound());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this error be more detailed, rather than just 404?

}

const stepIndex = parseInt(stepIndexCookie);

/**
* If we have no step index, we should error or if its an invalid number
*/

if (isNaN(stepIndex) || stepIndex < 0) {
return yield* Effect.fail(new HttpApiError.NotFound());
}

/**
* Match the body against validators now
* if the body has no match, we are defaulting to a successful response.
*/
const result = yield* validator(payload);

if (result === false) {
return yield* Effect.fail(new HttpApiError.Unauthorized());
}

/**
* We use the step index to find the next step in the response map.
* If the step index is out of bounds, we return a 404 Not Found error.
*/
const steps = responseMap[acr_value];

/**
* This may not be the best way to write this.
* An alternative option would be for us to include the success response we want to return,
* in the response map.
*
* then we can check if we are at the last step. if we are we write the cookie
* and then we return the success response (last item in array)
*
* for now, this returns a default success response and writes cookies.
*/
if (stepIndex + 1 >= steps.length) {
/**
* we need to return a success because we have not failed yet,
* and we have no more steps to process.
*/
const body = yield* HttpBody.json(returnSuccessResponseRedirect).pipe(
Effect.tap(Console.log(`here stepIndex: ${stepIndex}`)),
/**
* Decide on a better way to handle this error possibiltiy
*/
Effect.catchTag('HttpBodyError', () =>
Effect.fail(
new HttpApiError.HttpApiDecodeError({
message: 'Failed to encode body',
issues: [],
}),
),
),
);
return pipe(
HttpServerResponse.json(body),
HttpServerResponse.setCookie('ST', 'MockApiCookie123'),
HttpServerResponse.setCookie(
'interactionId',
returnSuccessResponseRedirect.interactionId,
{
httpOnly: true,
secure: true,
sameSite: 'strict',
},
),
HttpServerResponse.setCookie(
'interactionToken',
returnSuccessResponseRedirect.interactionToken,
{
httpOnly: true,
secure: true,
sameSite: 'strict',
},
),
HttpServerResponse.removeCookie('stepIndex'),
HttpServerResponse.setStatus(200),
HttpServerResponse.setHeader('Content-Type', 'application/json'),
);
}

/**
* The stepIndex middleware is used to auto-increment the step index
* based on the request type. If the step index is out of bounds,
* we return a 404 Not Found error. so we won't increment it, but we check for the next step
* in the flow.
*/
const nextStep = steps[stepIndex + 1];

return nextStep;
}).pipe(Effect.withSpan('Capabilities Handler Mock')),
),
);

export { CapabilitiesHandlerMock };
41 changes: 0 additions & 41 deletions e2e/mock-api-v2/src/handlers/custom-html-template.handler.ts

This file was deleted.

2 changes: 1 addition & 1 deletion e2e/mock-api-v2/src/handlers/revoke.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Tokens } from '../services/tokens.service.js';
import { HttpApiBuilder } from '@effect/platform';
import { Effect } from 'effect';

const RevokeTokenHandler = HttpApiBuilder.group(MockApi, 'TokenRevocation', (handlers) =>
const RevokeTokenHandler = HttpApiBuilder.group(MockApi, 'Revoke', (handlers) =>
handlers.handle('RevokeToken', () =>
Effect.gen(function* () {
const { revokeToken } = yield* Tokens;
Expand Down
2 changes: 1 addition & 1 deletion e2e/mock-api-v2/src/handlers/userinfo.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { UserInfo } from '../services/userinfo.service.js';
import { HttpApiBuilder } from '@effect/platform';
import { BearerToken } from '../middleware/Authorization.js';

const UserInfoMockHandler = HttpApiBuilder.group(MockApi, 'Protected Requests', (handlers) =>
const UserInfoMockHandler = HttpApiBuilder.group(MockApi, 'ProtectedRequests', (handlers) =>
handlers.handle('UserInfo', () =>
Effect.gen(function* () {
const authToken = yield* BearerToken;
Expand Down
35 changes: 17 additions & 18 deletions e2e/mock-api-v2/src/helpers/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@
*/
import { Effect, Match, Schema } from 'effect';

import { InvalidUsernamePassword, InvalidProtectNode } from '../errors/index.js';
import { PingOneCustomHtmlRequestBody } from '../schemas/custom-html-template/custom-html-template-request.schema.js';
import { HttpApiError } from '@effect/platform';
import { CapabilitiesRequestBody } from '../schemas/capabilities/capabilities.request.schema.js';

type PingRequestData = Schema.Schema.Type<
typeof PingOneCustomHtmlRequestBody
>['parameters']['data']['formData']['value'];
type PingRequestData = Schema.Schema.Type<typeof CapabilitiesRequestBody>;
/**
* Using this to match on the data types, realistically, this will be a schema of possible
* response bodies we want to validate against they validate to our conditions.
Expand All @@ -20,18 +18,19 @@ type PingRequestData = Schema.Schema.Type<
* or we can continue to the next step in the flow
*/
const validator = Match.type<PingRequestData>().pipe(
Match.when({ username: Match.string, password: Match.string }, ({ username, password }) => {
return Effect.if(username == 'testuser' && password === 'Password', {
onFalse: () => Effect.fail(new InvalidUsernamePassword()),
onTrue: () => Effect.succeed(true),
});
}),
Match.when({ pingprotectsdk: Match.string }, ({ pingprotectsdk }) => {
return Effect.if(pingprotectsdk.length > 1, {
onTrue: () => Effect.succeed(true),
onFalse: () => Effect.fail(new InvalidProtectNode()),
});
}),
Match.exhaustive,
Match.when(
{ parameters: { data: { formData: { username: Match.string, password: Match.string } } } },
({ parameters }) =>
Effect.if(
parameters.data.formData.username == 'testuser' &&
parameters.data.formData.password === 'Password',
{
onFalse: () => Effect.fail(new HttpApiError.Unauthorized()),
onTrue: () => Effect.succeed(true),
},
),
),
Match.orElse(() => Effect.succeed(true)),
);

export { validator, PingRequestData };
Empty file removed e2e/mock-api-v2/src/index.css
Empty file.
Loading
Loading