-
Notifications
You must be signed in to change notification settings - Fork 2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement user storage consent #1644
Changes from 14 commits
ad9aa63
be526b8
4e83535
698ae0b
a6fff85
d4adfef
62b687b
9d14d99
970cc41
7393cab
3ca2291
61a349d
75c4399
a0de057
648cca7
4a9f389
fcfeb9c
4ae4c23
f658274
c144b7b
c6a392b
d4547cd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,6 +15,11 @@ type Config = { | |
SAML_SP_METADATA_PATH: string; | ||
SAML_SP_SECRET_KEY_PATH: string; | ||
SAML_IDP_CERT?: string; | ||
AWS_S3_REGION: string; | ||
AWS_S3_ENDPOINT: string; | ||
AWS_S3_DATA_CONSENT_FGR_ACCESS_KEY: string; | ||
AWS_S3_DATA_CONSENT_FGR_SECRET_KEY: string; | ||
AWS_S3_DATA_CONSENT_FGR_BUCKET_NAME: string; | ||
Comment on lines
+18
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe we should drop the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I understand why Alex did this, based on my experience with the reference: https://docs.aws.amazon.com/cli/v1/userguide/cli-configure-envvars.html |
||
}; | ||
|
||
let instance: Config | undefined = undefined; | ||
|
@@ -47,6 +52,14 @@ export function config(): Config { | |
process.env.SAML_SP_SECRET_KEY_PATH?.trim() ?? | ||
path.join(process.cwd(), "data/saml/sp_privateKey.pem"), | ||
SAML_IDP_CERT: process.env.SAML_IDP_CERT?.trim(), | ||
AWS_S3_REGION: process.env.AWS_S3_REGION?.trim() ?? "", | ||
AWS_S3_ENDPOINT: process.env.AWS_S3_ENDPOINT?.trim() ?? "", | ||
AWS_S3_DATA_CONSENT_FGR_ACCESS_KEY: | ||
process.env.AWS_S3_DATA_CONSENT_FGR_ACCESS_KEY?.trim() ?? "", | ||
AWS_S3_DATA_CONSENT_FGR_SECRET_KEY: | ||
process.env.AWS_S3_DATA_CONSENT_FGR_SECRET_KEY?.trim() ?? "", | ||
AWS_S3_DATA_CONSENT_FGR_BUCKET_NAME: | ||
process.env.AWS_S3_DATA_CONSENT_FGR_BUCKET_NAME?.trim() ?? "", | ||
}; | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import type { Flow } from "~/domains/flows.server"; | ||
import { executeAsyncFlowActionByStepId } from "../flowTransitionValidation"; | ||
|
||
describe("flowTransitionValidation", () => { | ||
describe("executeAsyncFlowActionByStepId", () => { | ||
const mockRequest = new Request("http://localhost"); | ||
const mockAsyncFlowAction = vi.fn().mockResolvedValue(undefined); | ||
|
||
it("should execute the async flow action for the given stepId", async () => { | ||
const mockAsyncFlowAction = vi.fn().mockResolvedValue(undefined); | ||
const mockFlow: Flow = { | ||
asyncFlowActions: { | ||
"test-step": mockAsyncFlowAction, | ||
}, | ||
} as unknown as Flow; | ||
|
||
await executeAsyncFlowActionByStepId(mockFlow, "test-step", mockRequest); | ||
|
||
expect(mockAsyncFlowAction).toHaveBeenCalledWith(mockRequest); | ||
}); | ||
|
||
it("should not execute the async flow action for the given another stepId", async () => { | ||
const mockFlow: Flow = { | ||
asyncFlowActions: { | ||
"test-step": mockAsyncFlowAction, | ||
}, | ||
} as unknown as Flow; | ||
|
||
await executeAsyncFlowActionByStepId( | ||
mockFlow, | ||
"another-step", | ||
mockRequest, | ||
); | ||
|
||
expect(mockAsyncFlowAction).not.toHaveBeenCalledWith(mockRequest); | ||
}); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import { S3Client } from "@aws-sdk/client-s3"; | ||
import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
import { config } from "~/services/env/env.server"; | ||
import { createClientDataConsentFgr } from "~/services/s3/createClientDataConsentFgr"; | ||
|
||
vi.mock("@aws-sdk/client-s3", () => ({ | ||
S3Client: vi.fn(), | ||
})); | ||
|
||
vi.mock("~/services/env/env.server", () => ({ | ||
config: vi.fn(), | ||
})); | ||
|
||
describe("createClientDataConsentFgr", () => { | ||
beforeEach(() => { | ||
vi.clearAllMocks(); | ||
}); | ||
|
||
it("should create a new S3Client instance if not already created only once", () => { | ||
const mockConfig = { | ||
...config(), | ||
AWS_S3_DATA_CONSENT_FGR_ACCESS_KEY: "test-access-key", | ||
AWS_S3_DATA_CONSENT_FGR_SECRET_KEY: "test-secret-key", | ||
AWS_S3_REGION: "test-region", | ||
AWS_S3_ENDPOINT: "test-endpoint", | ||
}; | ||
|
||
vi.mocked(config).mockReturnValue(mockConfig); | ||
|
||
const firstClient = createClientDataConsentFgr(); | ||
|
||
expect(S3Client).toHaveBeenCalledWith({ | ||
region: mockConfig.AWS_S3_REGION, | ||
credentials: { | ||
accessKeyId: mockConfig.AWS_S3_DATA_CONSENT_FGR_ACCESS_KEY, | ||
secretAccessKey: mockConfig.AWS_S3_DATA_CONSENT_FGR_SECRET_KEY, | ||
}, | ||
endpoint: mockConfig.AWS_S3_ENDPOINT, | ||
}); | ||
expect(firstClient).toBeInstanceOf(S3Client); | ||
|
||
const secondClient = createClientDataConsentFgr(); | ||
expect(S3Client).toHaveBeenCalledTimes(1); | ||
expect(firstClient).toBe(secondClient); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; | ||
import { describe, it, expect, vi } from "vitest"; | ||
import { config } from "~/services/env/env.server"; | ||
import { sendSentryMessage } from "../../logging"; | ||
import { getSessionIdByFlowId } from "../../session.server"; | ||
import { createClientDataConsentFgr } from "../createClientDataConsentFgr"; | ||
import { storeConsentFgrToS3Bucket } from "../storeConsentFgrToS3Bucket"; | ||
|
||
vi.mock("@aws-sdk/client-s3", () => ({ | ||
PutObjectCommand: vi.fn(), | ||
})); | ||
|
||
vi.mock("../createClientDataConsentFgr", () => ({ | ||
createClientDataConsentFgr: vi.fn(), | ||
})); | ||
|
||
vi.mock("../../logging", () => ({ | ||
sendSentryMessage: vi.fn(), | ||
})); | ||
|
||
vi.mock("../../session.server", () => ({ | ||
getSessionIdByFlowId: vi.fn(), | ||
})); | ||
|
||
vi.mock("~/services/env/env.server", () => ({ | ||
config: vi.fn(), | ||
})); | ||
|
||
const mockS3Client = { send: vi.fn() } as unknown as S3Client; | ||
|
||
const mockRequest = new Request("http://localhost", { | ||
headers: { | ||
Cookie: "test-cookie", | ||
"user-agent": "test-agent", | ||
}, | ||
}); | ||
|
||
beforeEach(() => { | ||
vi.clearAllMocks(); | ||
vi.useFakeTimers(); | ||
}); | ||
|
||
afterEach(() => { | ||
vi.useRealTimers(); | ||
}); | ||
aaschlote marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
describe("storeConsentFgrToS3Bucket", () => { | ||
it("should store consent data to S3 bucket", async () => { | ||
const mockDate = new Date("2025-01-01"); | ||
vi.setSystemTime(mockDate); | ||
|
||
const mockSessionId = "test-session-id"; | ||
const mockConfig = { | ||
...config(), | ||
AWS_S3_DATA_CONSENT_FGR_BUCKET_NAME: "test-bucket", | ||
}; | ||
vi.mocked(createClientDataConsentFgr).mockReturnValue(mockS3Client); | ||
vi.mocked(getSessionIdByFlowId).mockResolvedValue(mockSessionId); | ||
vi.mocked(config).mockReturnValue(mockConfig); | ||
const mockBuffer = Buffer.from( | ||
`${mockSessionId};${mockDate.getTime()};test-agent`, | ||
"utf8", | ||
); | ||
const mockKey = "01-01-2025/test-session-id.csv"; | ||
|
||
await storeConsentFgrToS3Bucket(mockRequest); | ||
|
||
expect(createClientDataConsentFgr).toHaveBeenCalled(); | ||
expect(getSessionIdByFlowId).toHaveBeenCalledWith( | ||
"/fluggastrechte/formular", | ||
"test-cookie", | ||
); | ||
expect(mockS3Client.send).toHaveBeenCalledWith( | ||
new PutObjectCommand({ | ||
Bucket: mockConfig.AWS_S3_DATA_CONSENT_FGR_BUCKET_NAME, | ||
Body: mockBuffer, | ||
Key: mockKey, | ||
}), | ||
); | ||
}); | ||
|
||
it("should send a Sentry message on error", async () => { | ||
const mockError = new Error("Test error"); | ||
|
||
vi.mocked(createClientDataConsentFgr).mockImplementation(() => { | ||
throw mockError; | ||
}); | ||
|
||
await storeConsentFgrToS3Bucket(mockRequest); | ||
|
||
expect(sendSentryMessage).toHaveBeenCalledWith( | ||
`Error storing consent fgr data to S3 bucket: ${mockError.message}`, | ||
"error", | ||
); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
import { S3Client } from "@aws-sdk/client-s3"; | ||
import { config } from "~/services/env/env.server"; | ||
|
||
let instance: S3Client | undefined = undefined; | ||
|
||
export const createClientDataConsentFgr = () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would (strongly) vote for having a shared storage service, that we can then scope by app service (consent, file-upload, ...). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to be discussed in the Dev Exchange next week! |
||
if (typeof instance === "undefined") { | ||
const credentials = { | ||
accessKeyId: config().AWS_S3_DATA_CONSENT_FGR_ACCESS_KEY, | ||
secretAccessKey: config().AWS_S3_DATA_CONSENT_FGR_SECRET_KEY, | ||
}; | ||
|
||
instance = new S3Client({ | ||
region: config().AWS_S3_REGION, | ||
credentials, | ||
endpoint: config().AWS_S3_ENDPOINT, | ||
}); | ||
} | ||
|
||
return instance; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { PutObjectCommand } from "@aws-sdk/client-s3"; | ||
aaschlote marked this conversation as resolved.
Show resolved
Hide resolved
|
||
import { config } from "~/services/env/env.server"; | ||
import { today } from "~/util/date"; | ||
import { createClientDataConsentFgr } from "./createClientDataConsentFgr"; | ||
import { sendSentryMessage } from "../logging"; | ||
import { getSessionIdByFlowId } from "../session.server"; | ||
|
||
const createConsentDataBuffer = (sessionId: string, request: Request) => { | ||
aaschlote marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const userAgent = request.headers.get("user-agent"); | ||
return Buffer.from(`${sessionId};${Date.now()};${userAgent}`, "utf8"); | ||
}; | ||
|
||
const getFolderDate = () => { | ||
return today() | ||
.toLocaleDateString("de-DE", { | ||
day: "2-digit", | ||
month: "2-digit", | ||
year: "numeric", | ||
}) | ||
.replaceAll(".", "-"); | ||
}; | ||
aaschlote marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
export const storeConsentFgrToS3Bucket = async (request: Request) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if we use a shared storage service, this could be moved into its own There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also, I think this function only needs the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. for the first comment, this is file is related store something in the S3 bucket. As we already discussed, we renamed the folder to be For the second comment, I can change, but in case a new flow action is added and needs another data from the request (body, formData, referrer or etc), all the functions must be changed again. |
||
try { | ||
const s3Client = createClientDataConsentFgr(); | ||
const cookieHeader = request.headers.get("Cookie"); | ||
const sessionId = await getSessionIdByFlowId( | ||
"/fluggastrechte/formular", | ||
cookieHeader, | ||
); | ||
|
||
const buffer = createConsentDataBuffer(sessionId, request); | ||
const key = `${getFolderDate()}/${sessionId}.csv`; | ||
|
||
await s3Client.send( | ||
new PutObjectCommand({ | ||
Bucket: config().AWS_S3_DATA_CONSENT_FGR_BUCKET_NAME, | ||
Body: buffer, | ||
Key: key, | ||
}), | ||
); | ||
} catch (error) { | ||
const errorDescription = | ||
error instanceof Error ? error.message : "Unknown error"; | ||
|
||
sendSentryMessage( | ||
`Error storing consent fgr data to S3 bucket: ${errorDescription}`, | ||
"error", | ||
); | ||
} | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
one alternative I just thought of: adding it in the flow config, specifically under
meta
of that step. not sure whether that is more or less confusing 😅There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would prefer to have here for now, but we can move to the
steps
config from this PR once it's implemented 😃