-
Notifications
You must be signed in to change notification settings - Fork 60
feat: call member-tiers via m2m #2311
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鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
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 |
|---|---|---|
| @@ -1,3 +1,8 @@ | ||
| ORIGIN_URL=http://localhost:4000 | ||
| WORKER_SECRET=local-worker-secret | ||
| PAT_HASH_SALT=local-pat-hash-salt | ||
| LFX_API_URL= | ||
| M2M_ISSUER_URL= | ||
| M2M_AUDIENCE= | ||
| M2M_CLIENT_ID= | ||
| M2M_CLIENT_SECRET= |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| // Copyright (c) 2025 The Linux Foundation and each contributor. | ||
| // SPDX-License-Identifier: MIT | ||
| import type { Env } from './env'; | ||
|
|
||
| const EXPIRY_MARGIN_MS = 60_000; | ||
|
|
||
| let cached: { token: string; expiresAt: number } | undefined; | ||
|
|
||
| export async function m2mToken(env: Env): Promise<string> { | ||
| if (cached && cached.expiresAt > Date.now()) return cached.token; | ||
|
|
||
| const response = await fetch(new URL('oauth/token', env.M2M_ISSUER_URL), { | ||
| method: 'POST', | ||
| headers: { 'content-type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| grant_type: 'client_credentials', | ||
| client_id: env.M2M_CLIENT_ID, | ||
| client_secret: env.M2M_CLIENT_SECRET, | ||
| audience: env.M2M_AUDIENCE, | ||
| }), | ||
| }); | ||
| if (!response.ok) throw new Error(`M2M token request failed: ${response.status}`); | ||
|
|
||
| const body = (await response.json()) as { access_token: string; expires_in: number }; | ||
| cached = { | ||
| token: body.access_token, | ||
| expiresAt: Date.now() + body.expires_in * 1000 - EXPIRY_MARGIN_MS, | ||
| }; | ||
| return cached.token; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| // Copyright (c) 2025 The Linux Foundation and each contributor. | ||
| // SPDX-License-Identifier: MIT | ||
| import type { Env } from './env'; | ||
| import { m2mToken } from './m2m'; | ||
|
|
||
| export interface MemberOrgTier { | ||
| b2b_org_uid: string; | ||
|
|
@@ -17,18 +18,13 @@ export interface OrgTier { | |
| tier: string; | ||
| } | ||
|
|
||
| export async function fetchMemberTiers(_username: string, _env: Env): Promise<MemberOrgTier[]> { | ||
| return [ | ||
| { | ||
| b2b_org_uid: '001B000000IqhSLIAZ', | ||
| membership_uid: '02i2M000009ABCdIAM', | ||
| tier: 'gold', | ||
| company_name: 'Example Corp', | ||
| project_slug: 'lf-main', | ||
| tier_name: 'Gold Corporate Membership', | ||
| status: 'Active', | ||
| }, | ||
| ]; | ||
| export async function fetchMemberTiers(username: string, env: Env): Promise<MemberOrgTier[]> { | ||
| const url = new URL(`b2b_orgs/member-tiers/${encodeURIComponent(username)}?v=1`, env.LFX_API_URL); | ||
| const response = await fetch(url, { | ||
| headers: { authorization: `Bearer ${await m2mToken(env)}` }, | ||
| }); | ||
| if (!response.ok) throw new Error(`member-tiers request failed: ${response.status}`); | ||
|
Collaborator
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. what does member-tiers return for a user with no membership -
Collaborator
Author
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. member-tiers answers
Collaborator
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. What does member-tiers return for a user with no membership, a 200 with an empty array or a 404? If it can 404, this throw has nothing catching it in handle(), so the worker would answer with a raw 500 instead of the 403 that forbidden() already builds for exactly that case.
Collaborator
Author
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. member-tiers answers |
||
| return (await response.json()) as MemberOrgTier[]; | ||
|
epipav marked this conversation as resolved.
|
||
| } | ||
|
|
||
| export function pickOrgTier(tiers: MemberOrgTier[]): OrgTier | null { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| // Copyright (c) 2025 The Linux Foundation and each contributor. | ||
| // SPDX-License-Identifier: MIT | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import type { Env } from '../src/env'; | ||
| import { fetchMemberTiers } from '../src/tiers'; | ||
|
|
||
| const env = { | ||
| LFX_API_URL: 'https://lfx-api.test/', | ||
| M2M_ISSUER_URL: 'https://auth.test/', | ||
| M2M_AUDIENCE: 'https://lfx-api.test/', | ||
| M2M_CLIENT_ID: 'client', | ||
| M2M_CLIENT_SECRET: 'secret', | ||
| } as Env; | ||
|
|
||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| vi.useRealTimers(); | ||
| }); | ||
|
|
||
| describe('fetchMemberTiers', () => { | ||
| it('calls member-tiers with an M2M token and reuses the token', async () => { | ||
| const tiers = [{ b2b_org_uid: 'org', membership_uid: 'm', tier: 'gold' }]; | ||
| const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => | ||
| String(input).startsWith('https://auth.test/') | ||
| ? Response.json({ access_token: 'm2m-token', expires_in: 86400 }) | ||
| : Response.json(tiers), | ||
| ); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
|
|
||
| expect(await fetchMemberTiers('jane doe', env)).toEqual(tiers); | ||
| await fetchMemberTiers('jane doe', env); | ||
|
|
||
| const urls = fetchMock.mock.calls.map(([input]) => String(input)); | ||
| expect(urls).toEqual([ | ||
| 'https://auth.test/oauth/token', | ||
| 'https://lfx-api.test/b2b_orgs/member-tiers/jane%20doe?v=1', | ||
| 'https://lfx-api.test/b2b_orgs/member-tiers/jane%20doe?v=1', | ||
| ]); | ||
| const tokenInit = fetchMock.mock.calls[0]![1]!; | ||
| expect(tokenInit.method).toBe('POST'); | ||
| expect(JSON.parse(String(tokenInit.body))).toEqual({ | ||
| grant_type: 'client_credentials', | ||
| client_id: 'client', | ||
| client_secret: 'secret', | ||
| audience: 'https://lfx-api.test/', | ||
| }); | ||
| const init = fetchMock.mock.calls[1]![1]!; | ||
| expect(new Headers(init.headers).get('authorization')).toBe('Bearer m2m-token'); | ||
| }); | ||
|
epipav marked this conversation as resolved.
|
||
|
|
||
| it('fetches a new token once the cached one is inside the expiry margin', async () => { | ||
| vi.useFakeTimers({ now: Date.now() + 2 * 86_400_000 }); | ||
| const fetchMock = vi.fn(async (input: RequestInfo | URL) => | ||
| String(input).startsWith('https://auth.test/') | ||
| ? Response.json({ access_token: 'short-token', expires_in: 120 }) | ||
| : Response.json([]), | ||
| ); | ||
| vi.stubGlobal('fetch', fetchMock); | ||
|
|
||
| await fetchMemberTiers('jane', env); | ||
| await fetchMemberTiers('jane', env); | ||
| vi.advanceTimersByTime(61_000); | ||
| await fetchMemberTiers('jane', env); | ||
|
|
||
| const tokenCalls = fetchMock.mock.calls.filter(([input]) => | ||
| String(input).endsWith('/oauth/token'), | ||
| ); | ||
| expect(tokenCalls).toHaveLength(2); | ||
| }); | ||
|
|
||
| it('throws when member-tiers rejects the call', async () => { | ||
| vi.stubGlobal('fetch', async (input: RequestInfo | URL) => | ||
| String(input).startsWith('https://auth.test/') | ||
| ? Response.json({ access_token: 'm2m-token', expires_in: 86400 }) | ||
| : new Response('forbidden', { status: 403 }), | ||
| ); | ||
| await expect(fetchMemberTiers('jane', env)).rejects.toThrow('member-tiers request failed: 403'); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.