@contember/engine-tenant-api 2.1.0-rc.1 → 2.1.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/development/src/model/queries/membership/ProjectMembersQuery.cjs +1 -1
- package/dist/development/src/model/queries/membership/ProjectMembersQuery.cjs.map +1 -1
- package/dist/development/src/model/queries/membership/ProjectMembersQuery.js +1 -1
- package/dist/development/src/model/queries/membership/ProjectMembersQuery.js.map +1 -1
- package/dist/development/src/model/service/PersonAccessManager.cjs +2 -2
- package/dist/development/src/model/service/PersonAccessManager.cjs.map +1 -1
- package/dist/development/src/model/service/PersonAccessManager.js +2 -2
- package/dist/development/src/model/service/PersonAccessManager.js.map +1 -1
- package/dist/development/src/model/service/apiKey/ApiKeyManager.cjs +4 -3
- package/dist/development/src/model/service/apiKey/ApiKeyManager.cjs.map +1 -1
- package/dist/development/src/model/service/apiKey/ApiKeyManager.js +4 -3
- package/dist/development/src/model/service/apiKey/ApiKeyManager.js.map +1 -1
- package/dist/development/src/model/service/idp/IDPManager.cjs +28 -2
- package/dist/development/src/model/service/idp/IDPManager.cjs.map +1 -1
- package/dist/development/src/model/service/idp/IDPManager.js +28 -2
- package/dist/development/src/model/service/idp/IDPManager.js.map +1 -1
- package/dist/development/src/model/utils/DatabaseContext.cjs +7 -2
- package/dist/development/src/model/utils/DatabaseContext.cjs.map +1 -1
- package/dist/development/src/model/utils/DatabaseContext.js +8 -3
- package/dist/development/src/model/utils/DatabaseContext.js.map +1 -1
- package/dist/development/src/resolvers/mutation/person/DisablePersonMutationResolver.cjs +1 -1
- package/dist/development/src/resolvers/mutation/person/DisablePersonMutationResolver.cjs.map +1 -1
- package/dist/development/src/resolvers/mutation/person/DisablePersonMutationResolver.js +1 -1
- package/dist/development/src/resolvers/mutation/person/DisablePersonMutationResolver.js.map +1 -1
- package/dist/production/src/model/queries/membership/ProjectMembersQuery.cjs +1 -1
- package/dist/production/src/model/queries/membership/ProjectMembersQuery.cjs.map +1 -1
- package/dist/production/src/model/queries/membership/ProjectMembersQuery.js +1 -1
- package/dist/production/src/model/queries/membership/ProjectMembersQuery.js.map +1 -1
- package/dist/production/src/model/service/PersonAccessManager.cjs +2 -2
- package/dist/production/src/model/service/PersonAccessManager.cjs.map +1 -1
- package/dist/production/src/model/service/PersonAccessManager.js +2 -2
- package/dist/production/src/model/service/PersonAccessManager.js.map +1 -1
- package/dist/production/src/model/service/apiKey/ApiKeyManager.cjs +4 -3
- package/dist/production/src/model/service/apiKey/ApiKeyManager.cjs.map +1 -1
- package/dist/production/src/model/service/apiKey/ApiKeyManager.js +4 -3
- package/dist/production/src/model/service/apiKey/ApiKeyManager.js.map +1 -1
- package/dist/production/src/model/service/idp/IDPManager.cjs +28 -2
- package/dist/production/src/model/service/idp/IDPManager.cjs.map +1 -1
- package/dist/production/src/model/service/idp/IDPManager.js +28 -2
- package/dist/production/src/model/service/idp/IDPManager.js.map +1 -1
- package/dist/production/src/model/utils/DatabaseContext.cjs +7 -2
- package/dist/production/src/model/utils/DatabaseContext.cjs.map +1 -1
- package/dist/production/src/model/utils/DatabaseContext.js +8 -3
- package/dist/production/src/model/utils/DatabaseContext.js.map +1 -1
- package/dist/production/src/resolvers/mutation/person/DisablePersonMutationResolver.cjs +1 -1
- package/dist/production/src/resolvers/mutation/person/DisablePersonMutationResolver.cjs.map +1 -1
- package/dist/production/src/resolvers/mutation/person/DisablePersonMutationResolver.js +1 -1
- package/dist/production/src/resolvers/mutation/person/DisablePersonMutationResolver.js.map +1 -1
- package/dist/tests/cases/unit/databaseContextRetry.test.d.ts +2 -0
- package/dist/tests/cases/unit/databaseContextRetry.test.d.ts.map +1 -0
- package/dist/tests/cases/unit/idp/idpManagerUnknownConfigKeys.test.d.ts +2 -0
- package/dist/tests/cases/unit/idp/idpManagerUnknownConfigKeys.test.d.ts.map +1 -0
- package/dist/tests/tsconfig.tsbuildinfo +1 -1
- package/dist/types/model/queries/membership/ProjectMembersQuery.d.ts.map +1 -1
- package/dist/types/model/service/PersonAccessManager.d.ts +2 -1
- package/dist/types/model/service/PersonAccessManager.d.ts.map +1 -1
- package/dist/types/model/service/apiKey/ApiKeyManager.d.ts.map +1 -1
- package/dist/types/model/service/idp/IDPManager.d.ts +18 -0
- package/dist/types/model/service/idp/IDPManager.d.ts.map +1 -1
- package/dist/types/model/utils/DatabaseContext.d.ts +14 -1
- package/dist/types/model/utils/DatabaseContext.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/model/queries/membership/ProjectMembersQuery.ts +1 -0
- package/src/model/service/PersonAccessManager.ts +5 -2
- package/src/model/service/apiKey/ApiKeyManager.ts +5 -3
- package/src/model/service/idp/IDPManager.ts +30 -2
- package/src/model/utils/DatabaseContext.ts +27 -6
- package/src/resolvers/mutation/person/DisablePersonMutationResolver.ts +1 -1
- package/tests/cases/integration/mocked/listProjectMembersQuery.test.ts +8 -3
- package/tests/cases/unit/databaseContextRetry.test.ts +80 -0
- package/tests/cases/unit/idp/idpManagerUnknownConfigKeys.test.ts +101 -0
package/package.json
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Logger } from '@contember/logger'
|
|
1
2
|
import { PersonQuery, PersonRow } from '../queries'
|
|
2
3
|
import { DatabaseContext } from '../utils'
|
|
3
4
|
import { DisablePersonCommand } from '../commands/person/DisablePersonCommand'
|
|
@@ -8,7 +9,9 @@ import { DisablePersonErrorCode } from '../../schema'
|
|
|
8
9
|
class PersonAccessManager {
|
|
9
10
|
constructor(private readonly apiKeyManager: ApiKeyManager) {}
|
|
10
11
|
|
|
11
|
-
async disablePerson(dbContext: DatabaseContext, person: PersonRow): Promise<PersonDisableAccessResponse> {
|
|
12
|
+
async disablePerson(dbContext: DatabaseContext, person: PersonRow, logger: Logger): Promise<PersonDisableAccessResponse> {
|
|
13
|
+
// Retried: the api_key update races the target's own background session tracking and loses with a 40001,
|
|
14
|
+
// which would otherwise leave the person enabled behind an internal error. Nothing here escapes the transaction.
|
|
12
15
|
return await dbContext.transaction(async trx => {
|
|
13
16
|
if (person.disabled_at !== null) {
|
|
14
17
|
return new ResponseError('PERSON_ALREADY_DISABLED', 'Person is already disabled')
|
|
@@ -19,7 +22,7 @@ class PersonAccessManager {
|
|
|
19
22
|
await this.disableIdentityApiKeys(trx, person.identity_id)
|
|
20
23
|
|
|
21
24
|
return new ResponseOk(null)
|
|
22
|
-
})
|
|
25
|
+
}, { retry: { logger } })
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
private async disablePersonAccount(dbContext: DatabaseContext, personId: string) {
|
|
@@ -44,15 +44,17 @@ export class ApiKeyManager {
|
|
|
44
44
|
return new ResponseError(VerifyErrorCode.DISABLED, `API key expired at ${apiKeyRow.expires_at.toISOString()}`)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
// Detached (runs after the response), so a rejection here would be an unhandledRejection and can take the
|
|
48
|
+
// worker down. Tracking is best-effort — the next request writes it again.
|
|
49
|
+
setImmediate(() => {
|
|
50
|
+
dbContext.commandBus.execute(
|
|
49
51
|
new ProlongApiKeyCommand(
|
|
50
52
|
apiKeyRow.id,
|
|
51
53
|
apiKeyRow.type,
|
|
52
54
|
apiKeyRow.expiration,
|
|
53
55
|
apiKeyRow.expires_at,
|
|
54
56
|
),
|
|
55
|
-
)
|
|
57
|
+
).catch(() => {})
|
|
56
58
|
})
|
|
57
59
|
|
|
58
60
|
return new ResponseOk(new VerifyResult(apiKeyRow.identity_id, apiKeyRow.id, apiKeyRow.roles, apiKeyRow.person_id))
|
|
@@ -22,7 +22,7 @@ export class IDPManager {
|
|
|
22
22
|
return await db.transaction(async (db): Promise<RegisterIDPResponse> => {
|
|
23
23
|
try {
|
|
24
24
|
const providerService = this.idpRegistry.getHandler(idp.type)
|
|
25
|
-
providerService.validateConfiguration(idp.configuration)
|
|
25
|
+
this.assertNoUnknownConfigurationKeys(providerService.validateConfiguration(idp.configuration), idp.configuration)
|
|
26
26
|
} catch (e) {
|
|
27
27
|
if (e instanceof IdentityProviderNotFoundError) {
|
|
28
28
|
return new ResponseError('UNKNOWN_TYPE', `IDP type ${idp.type} not found`)
|
|
@@ -99,7 +99,7 @@ export class IDPManager {
|
|
|
99
99
|
const type = data.type ?? existing.type
|
|
100
100
|
const providerService = this.idpRegistry.getHandler(type)
|
|
101
101
|
if (data.configuration !== undefined || type !== existing.type) {
|
|
102
|
-
providerService.validateConfiguration(newConfiguration)
|
|
102
|
+
this.assertNoUnknownConfigurationKeys(providerService.validateConfiguration(newConfiguration), newConfiguration)
|
|
103
103
|
}
|
|
104
104
|
} catch (e) {
|
|
105
105
|
if (e instanceof InvalidIDPConfigurationError) {
|
|
@@ -119,6 +119,34 @@ export class IDPManager {
|
|
|
119
119
|
})
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Reject top-level `configuration` keys the provider does not recognise.
|
|
124
|
+
*
|
|
125
|
+
* Every provider configuration is parsed with `Typesafe.partial`, which silently DROPS unknown
|
|
126
|
+
* properties. Without this check `addIDP` / `updateIDP` answer `ok` to a configuration the
|
|
127
|
+
* provider will never act on — a typo like `fetchUserinfo` (lowercase i) reads as a successfully
|
|
128
|
+
* applied setting while the option stays off, and an option that a given engine version does not
|
|
129
|
+
* support yet looks indistinguishable from one that works.
|
|
130
|
+
*
|
|
131
|
+
* The parsed result carries exactly the keys the provider understood, so the difference against
|
|
132
|
+
* the submitted object is the set of ignored keys. All of them are reported at once (rather than
|
|
133
|
+
* the first, as `Typesafe.noExtraProps` would) so a configuration with several typos takes one
|
|
134
|
+
* round trip to fix.
|
|
135
|
+
*
|
|
136
|
+
* Write time only: reading a stored configuration is deliberately left untouched, so a row written
|
|
137
|
+
* before this check keeps authenticating instead of locking its users out.
|
|
138
|
+
*/
|
|
139
|
+
private assertNoUnknownConfigurationKeys(parsed: {}, submitted: Record<string, unknown>): void {
|
|
140
|
+
const known = new Set(Object.keys(parsed))
|
|
141
|
+
const unknown = Object.keys(submitted).filter(key => !known.has(key))
|
|
142
|
+
if (unknown.length > 0) {
|
|
143
|
+
throw new InvalidIDPConfigurationError(
|
|
144
|
+
`unknown configuration ${unknown.length === 1 ? 'property' : 'properties'} ${unknown.join(', ')}; `
|
|
145
|
+
+ `remove ${unknown.length === 1 ? 'it' : 'them'} or check the spelling — the provider would ignore ${unknown.length === 1 ? 'it' : 'them'}`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
122
150
|
public async listIDP(db: DatabaseContext): Promise<IdentityProviderDto[]> {
|
|
123
151
|
const result = await db.queryHandler.fetch(new IdentityProvidersQuery())
|
|
124
152
|
return result.map(it => {
|
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
import { Client, Connection } from '@contember/database'
|
|
1
|
+
import { Client, Connection, retryTransaction } from '@contember/database'
|
|
2
|
+
import { Logger } from '@contember/logger'
|
|
2
3
|
import { Providers } from '../providers'
|
|
3
4
|
import { CommandBus } from '../commands'
|
|
4
5
|
import { BatchLoaderArgs, initBatchLoader, ItemLoader } from '../../utils/batchQuery'
|
|
5
6
|
|
|
7
|
+
export interface TransactionOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Repeat the transaction when postgres aborts it with a serialization failure (40001) — the standing cost of the
|
|
10
|
+
* repeatable-read level every transaction here runs at.
|
|
11
|
+
*
|
|
12
|
+
* Opt in only when the callback has no effect outside the transaction: several managers send a mail from inside
|
|
13
|
+
* one (InviteManager, EmailChangeManager, PasswordlessSignInManager) and a retry would send it twice.
|
|
14
|
+
*/
|
|
15
|
+
retry?: { logger: Logger }
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
export class DatabaseContext<Conn extends Connection.ConnectionLike = Connection.ConnectionLike> {
|
|
7
19
|
private loaders = new Map<BatchLoaderArgs<any, any, any>, ItemLoader<any, any>>()
|
|
8
20
|
|
|
@@ -20,11 +32,20 @@ export class DatabaseContext<Conn extends Connection.ConnectionLike = Connection
|
|
|
20
32
|
return this.client.createQueryHandler()
|
|
21
33
|
}
|
|
22
34
|
|
|
23
|
-
public async transaction<T>(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
public async transaction<T>(
|
|
36
|
+
cb: (dbContext: DatabaseContext<Connection.TransactionLike>) => Promise<T>,
|
|
37
|
+
options: TransactionOptions = {},
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
const run = async () =>
|
|
40
|
+
await this.client.transaction(async db => {
|
|
41
|
+
await db.query(Connection.REPEATABLE_READ)
|
|
42
|
+
return await cb(new DatabaseContext(db, this.providers))
|
|
43
|
+
})
|
|
44
|
+
const retry = options.retry
|
|
45
|
+
if (!retry) {
|
|
46
|
+
return await run()
|
|
47
|
+
}
|
|
48
|
+
return await retryTransaction(run, message => retry.logger.warn(message))
|
|
28
49
|
}
|
|
29
50
|
|
|
30
51
|
public async scope<T>(cb: (dbContext: DatabaseContext<Connection.AcquiredConnectionLike>) => Promise<T>): Promise<T> {
|
|
@@ -32,7 +32,7 @@ export class DisablePersonMutationResolver implements MutationResolvers {
|
|
|
32
32
|
message: 'You are not allowed to disable person account',
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
-
const result = await this.personAccessManager.disablePerson(context.db, targetPerson)
|
|
35
|
+
const result = await this.personAccessManager.disablePerson(context.db, targetPerson, context.logger)
|
|
36
36
|
await context.logAuthAction({
|
|
37
37
|
type: 'person_disable',
|
|
38
38
|
response: result,
|
|
@@ -4,14 +4,18 @@ import { testUuid } from '../../../src/testUuid'
|
|
|
4
4
|
import { test } from 'bun:test'
|
|
5
5
|
import { sqlTransaction } from './sql/sqlTransaction'
|
|
6
6
|
|
|
7
|
-
test('list project members by email query', async () => {
|
|
7
|
+
test('list project members by email query with stable pagination order', async () => {
|
|
8
8
|
await executeTenantTest({
|
|
9
9
|
query: {
|
|
10
10
|
query: GQL`
|
|
11
11
|
query {
|
|
12
12
|
projectBySlug(slug: "sandbox") {
|
|
13
13
|
members(
|
|
14
|
-
input: {
|
|
14
|
+
input: {
|
|
15
|
+
filter: { email: ["foo@localhost", "bar@localhost"], memberType: PERSON }
|
|
16
|
+
limit: 10
|
|
17
|
+
offset: 20
|
|
18
|
+
}
|
|
15
19
|
) {
|
|
16
20
|
identity {
|
|
17
21
|
id
|
|
@@ -41,7 +45,8 @@ query {
|
|
|
41
45
|
sql: `select "id", "description" from "tenant"."identity"
|
|
42
46
|
where exists (select ?::int from "tenant"."project_membership"
|
|
43
47
|
where "project_membership"."identity_id" = "identity"."id" and "project_id" = ?)
|
|
44
|
-
|
|
48
|
+
and exists (select ?::int from "tenant"."person" where "person"."identity_id" = "identity"."id" and "email" in (?, ?))
|
|
49
|
+
order by "identity"."id" asc limit 10 offset 20`,
|
|
45
50
|
parameters: [1, testUuid(1), 1, 'foo@localhost', 'bar@localhost'],
|
|
46
51
|
response: {
|
|
47
52
|
rows: [
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { createConnectionMock, ExpectedQuery } from '@contember/database-tester'
|
|
3
|
+
import { SerializationFailureError } from '@contember/database'
|
|
4
|
+
import { createLogger, TestLoggerHandler } from '@contember/logger'
|
|
5
|
+
import { DatabaseContext } from '../../../src/index'
|
|
6
|
+
import { Providers } from '../../../src/model/providers'
|
|
7
|
+
|
|
8
|
+
// Every tenant transaction runs at repeatable read, so postgres aborts it with a 40001 as soon as another
|
|
9
|
+
// transaction commits to a row it wants — the api_key row disablePerson touches is written by that very
|
|
10
|
+
// person's background session tracking. Repeating is the only correct answer, but it is opt-in: several
|
|
11
|
+
// managers send a mail from inside their transaction and would send it twice.
|
|
12
|
+
|
|
13
|
+
const providers: Providers = {
|
|
14
|
+
uuid: () => '11111111-1111-1111-1111-111111111111',
|
|
15
|
+
now: () => new Date('2026-01-01T00:00:00Z'),
|
|
16
|
+
randomBytes: length => Promise.resolve(Buffer.alloc(length)),
|
|
17
|
+
bcrypt: value => Promise.resolve(value),
|
|
18
|
+
bcryptCompare: (data, encrypted) => Promise.resolve(data === encrypted),
|
|
19
|
+
hash: value => Buffer.from(String(value)),
|
|
20
|
+
encrypt: () => {
|
|
21
|
+
throw new Error('not supported')
|
|
22
|
+
},
|
|
23
|
+
decrypt: () => {
|
|
24
|
+
throw new Error('not supported')
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const serializationFailure = () =>
|
|
29
|
+
new SerializationFailureError('update "tenant"."api_key" set "disabled_at" = $1 where "identity_id" = $2', [], {
|
|
30
|
+
code: '40001',
|
|
31
|
+
message: 'could not serialize access due to concurrent update',
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const begin: ExpectedQuery[] = [
|
|
35
|
+
{ sql: 'BEGIN;', response: { rowCount: 1 } },
|
|
36
|
+
{ sql: 'SET TRANSACTION ISOLATION LEVEL REPEATABLE READ', response: { rowCount: 1 } },
|
|
37
|
+
]
|
|
38
|
+
const commit: ExpectedQuery = { sql: 'COMMIT;', response: { rowCount: 1 } }
|
|
39
|
+
|
|
40
|
+
const createDbContext = (queries: ExpectedQuery[]) => {
|
|
41
|
+
const connection = createConnectionMock(queries)
|
|
42
|
+
return new DatabaseContext(connection.createClient('tenant', { module: 'tenant' }), providers)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('transaction with retry repeats the whole transaction after a serialization failure', async () => {
|
|
46
|
+
const queries = [...begin, ...begin, commit]
|
|
47
|
+
const dbContext = createDbContext(queries)
|
|
48
|
+
const handler = new TestLoggerHandler()
|
|
49
|
+
let attempts = 0
|
|
50
|
+
|
|
51
|
+
const result = await dbContext.transaction(async () => {
|
|
52
|
+
attempts++
|
|
53
|
+
if (attempts === 1) {
|
|
54
|
+
throw serializationFailure()
|
|
55
|
+
}
|
|
56
|
+
return 'done'
|
|
57
|
+
}, { retry: { logger: createLogger(handler) } })
|
|
58
|
+
|
|
59
|
+
expect(result).toBe('done')
|
|
60
|
+
expect(attempts).toBe(2)
|
|
61
|
+
// The second attempt opens its own transaction, so it gets a fresh snapshot — a retry on the same one
|
|
62
|
+
// would abort again forever.
|
|
63
|
+
expect(queries).toHaveLength(0)
|
|
64
|
+
// Logged through the injected logger, not an ambient one — a retry must not depend on request-scoped state.
|
|
65
|
+
expect(handler.messages).toHaveLength(1)
|
|
66
|
+
expect(handler.messages[0].message).toMatch(/^RETRY: Serialization failure \(attempt #1/)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('transaction without retry lets the serialization failure through', async () => {
|
|
70
|
+
const dbContext = createDbContext([...begin])
|
|
71
|
+
let attempts = 0
|
|
72
|
+
|
|
73
|
+
const run = dbContext.transaction(async () => {
|
|
74
|
+
attempts++
|
|
75
|
+
throw serializationFailure()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
await expect(run).rejects.toBeInstanceOf(SerializationFailureError)
|
|
79
|
+
expect(attempts).toBe(1)
|
|
80
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { IDPHandlerRegistry, IDPManager } from '../../../../src/model/service/idp'
|
|
3
|
+
import { OIDCProvider } from '../../../../src/model/service/idp/providers/OIDCProvider'
|
|
4
|
+
import { DatabaseContext } from '../../../../src'
|
|
5
|
+
|
|
6
|
+
// Provider configurations are parsed with `Typesafe.partial`, which DROPS unknown properties. Without a
|
|
7
|
+
// write-time guard, `addIDP` / `updateIDP` answer `ok` to a configuration the provider will never act on:
|
|
8
|
+
// a typo such as `fetchUserinfo` (lowercase i) reads as an applied setting while the option stays off.
|
|
9
|
+
// These pin the rejection. `OIDCProvider.validateConfiguration` is pure Typesafe parsing (no discovery,
|
|
10
|
+
// no network), so the real provider can be used here rather than a pass-through double.
|
|
11
|
+
|
|
12
|
+
const OIDC_BASE = { url: 'https://idp.example.com', clientId: 'client', clientSecret: 'secret' }
|
|
13
|
+
const OPTIONS = { autoSignUp: false, exclusive: false, initReturnsConfig: false }
|
|
14
|
+
|
|
15
|
+
const makeManager = () => {
|
|
16
|
+
const registry = new IDPHandlerRegistry()
|
|
17
|
+
registry.registerHandler('oidc', new OIDCProvider())
|
|
18
|
+
return new IDPManager(registry)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `existing` undefined ⇒ no IdP with that slug yet (the addIDP path). */
|
|
22
|
+
const makeDb = (existing?: Record<string, unknown>) => {
|
|
23
|
+
const executed: unknown[] = []
|
|
24
|
+
const inner: any = {
|
|
25
|
+
queryHandler: {
|
|
26
|
+
fetch: async () => existing ? { id: 'idp-1', slug: 'sso', type: 'oidc', configuration: existing, disabledAt: null } : undefined,
|
|
27
|
+
},
|
|
28
|
+
commandBus: { execute: async (command: unknown) => void executed.push(command) },
|
|
29
|
+
}
|
|
30
|
+
inner.transaction = async (cb: (db: any) => Promise<unknown>) => cb(inner)
|
|
31
|
+
return { db: inner as unknown as DatabaseContext, executed }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('IDPManager — unknown configuration keys', () => {
|
|
35
|
+
test('addIDP rejects a key the provider would silently ignore', async () => {
|
|
36
|
+
const { db, executed } = makeDb()
|
|
37
|
+
const response = await makeManager().addIDP(db, {
|
|
38
|
+
slug: 'sso',
|
|
39
|
+
type: 'oidc',
|
|
40
|
+
// `fetchUserinfo` is a plausible typo of `fetchUserInfo`; before the guard this returned ok
|
|
41
|
+
// and left the option off.
|
|
42
|
+
configuration: { ...OIDC_BASE, fetchUserinfo: true },
|
|
43
|
+
options: OPTIONS,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(response.ok).toBe(false)
|
|
47
|
+
expect(response.ok === false && response.error).toBe('INVALID_CONFIGURATION')
|
|
48
|
+
expect(response.ok === false && response.errorMessage).toContain('fetchUserinfo')
|
|
49
|
+
// nothing was written
|
|
50
|
+
expect(executed).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('reports every unknown key at once, so several typos take one round trip', async () => {
|
|
54
|
+
const { db } = makeDb()
|
|
55
|
+
const response = await makeManager().addIDP(db, {
|
|
56
|
+
slug: 'sso',
|
|
57
|
+
type: 'oidc',
|
|
58
|
+
configuration: { ...OIDC_BASE, fetchUserinfo: true, idTokenSignedResponseAlgo: 'RS256' },
|
|
59
|
+
options: OPTIONS,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
expect(response.ok).toBe(false)
|
|
63
|
+
const message = response.ok === false ? response.errorMessage ?? '' : ''
|
|
64
|
+
expect(message).toContain('fetchUserinfo')
|
|
65
|
+
expect(message).toContain('idTokenSignedResponseAlgo')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('a configuration the provider fully understands still passes', async () => {
|
|
69
|
+
const { db, executed } = makeDb()
|
|
70
|
+
const response = await makeManager().addIDP(db, {
|
|
71
|
+
slug: 'sso',
|
|
72
|
+
type: 'oidc',
|
|
73
|
+
configuration: { ...OIDC_BASE, scope: 'openid email', fetchUserInfo: true },
|
|
74
|
+
options: OPTIONS,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(response.ok).toBe(true)
|
|
78
|
+
expect(executed).toHaveLength(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('updateIDP rejects an unknown key in the MERGED result, not just in the patch', async () => {
|
|
82
|
+
const { db, executed } = makeDb(OIDC_BASE)
|
|
83
|
+
const response = await makeManager().updateIDP(db, 'sso', { configuration: { fetchUserinfo: true } }, true)
|
|
84
|
+
|
|
85
|
+
expect(response.ok).toBe(false)
|
|
86
|
+
expect(response.ok === false && response.error).toBe('INVALID_CONFIGURATION')
|
|
87
|
+
expect(response.ok === false && response.errorMessage).toContain('fetchUserinfo')
|
|
88
|
+
expect(executed).toHaveLength(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('an update that does not touch the configuration is not re-validated', async () => {
|
|
92
|
+
// A row written before this guard may hold keys the provider ignores. Toggling something else
|
|
93
|
+
// about it must not become impossible — the guard is a write-time check on a submitted
|
|
94
|
+
// configuration, not a re-validation of what is already stored.
|
|
95
|
+
const { db, executed } = makeDb({ ...OIDC_BASE, legacyIgnoredKey: true })
|
|
96
|
+
const response = await makeManager().updateIDP(db, 'sso', { options: { autoSignUp: true } }, false)
|
|
97
|
+
|
|
98
|
+
expect(response.ok).toBe(true)
|
|
99
|
+
expect(executed).toHaveLength(1)
|
|
100
|
+
})
|
|
101
|
+
})
|