@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.
Files changed (72) hide show
  1. package/dist/development/src/model/queries/membership/ProjectMembersQuery.cjs +1 -1
  2. package/dist/development/src/model/queries/membership/ProjectMembersQuery.cjs.map +1 -1
  3. package/dist/development/src/model/queries/membership/ProjectMembersQuery.js +1 -1
  4. package/dist/development/src/model/queries/membership/ProjectMembersQuery.js.map +1 -1
  5. package/dist/development/src/model/service/PersonAccessManager.cjs +2 -2
  6. package/dist/development/src/model/service/PersonAccessManager.cjs.map +1 -1
  7. package/dist/development/src/model/service/PersonAccessManager.js +2 -2
  8. package/dist/development/src/model/service/PersonAccessManager.js.map +1 -1
  9. package/dist/development/src/model/service/apiKey/ApiKeyManager.cjs +4 -3
  10. package/dist/development/src/model/service/apiKey/ApiKeyManager.cjs.map +1 -1
  11. package/dist/development/src/model/service/apiKey/ApiKeyManager.js +4 -3
  12. package/dist/development/src/model/service/apiKey/ApiKeyManager.js.map +1 -1
  13. package/dist/development/src/model/service/idp/IDPManager.cjs +28 -2
  14. package/dist/development/src/model/service/idp/IDPManager.cjs.map +1 -1
  15. package/dist/development/src/model/service/idp/IDPManager.js +28 -2
  16. package/dist/development/src/model/service/idp/IDPManager.js.map +1 -1
  17. package/dist/development/src/model/utils/DatabaseContext.cjs +7 -2
  18. package/dist/development/src/model/utils/DatabaseContext.cjs.map +1 -1
  19. package/dist/development/src/model/utils/DatabaseContext.js +8 -3
  20. package/dist/development/src/model/utils/DatabaseContext.js.map +1 -1
  21. package/dist/development/src/resolvers/mutation/person/DisablePersonMutationResolver.cjs +1 -1
  22. package/dist/development/src/resolvers/mutation/person/DisablePersonMutationResolver.cjs.map +1 -1
  23. package/dist/development/src/resolvers/mutation/person/DisablePersonMutationResolver.js +1 -1
  24. package/dist/development/src/resolvers/mutation/person/DisablePersonMutationResolver.js.map +1 -1
  25. package/dist/production/src/model/queries/membership/ProjectMembersQuery.cjs +1 -1
  26. package/dist/production/src/model/queries/membership/ProjectMembersQuery.cjs.map +1 -1
  27. package/dist/production/src/model/queries/membership/ProjectMembersQuery.js +1 -1
  28. package/dist/production/src/model/queries/membership/ProjectMembersQuery.js.map +1 -1
  29. package/dist/production/src/model/service/PersonAccessManager.cjs +2 -2
  30. package/dist/production/src/model/service/PersonAccessManager.cjs.map +1 -1
  31. package/dist/production/src/model/service/PersonAccessManager.js +2 -2
  32. package/dist/production/src/model/service/PersonAccessManager.js.map +1 -1
  33. package/dist/production/src/model/service/apiKey/ApiKeyManager.cjs +4 -3
  34. package/dist/production/src/model/service/apiKey/ApiKeyManager.cjs.map +1 -1
  35. package/dist/production/src/model/service/apiKey/ApiKeyManager.js +4 -3
  36. package/dist/production/src/model/service/apiKey/ApiKeyManager.js.map +1 -1
  37. package/dist/production/src/model/service/idp/IDPManager.cjs +28 -2
  38. package/dist/production/src/model/service/idp/IDPManager.cjs.map +1 -1
  39. package/dist/production/src/model/service/idp/IDPManager.js +28 -2
  40. package/dist/production/src/model/service/idp/IDPManager.js.map +1 -1
  41. package/dist/production/src/model/utils/DatabaseContext.cjs +7 -2
  42. package/dist/production/src/model/utils/DatabaseContext.cjs.map +1 -1
  43. package/dist/production/src/model/utils/DatabaseContext.js +8 -3
  44. package/dist/production/src/model/utils/DatabaseContext.js.map +1 -1
  45. package/dist/production/src/resolvers/mutation/person/DisablePersonMutationResolver.cjs +1 -1
  46. package/dist/production/src/resolvers/mutation/person/DisablePersonMutationResolver.cjs.map +1 -1
  47. package/dist/production/src/resolvers/mutation/person/DisablePersonMutationResolver.js +1 -1
  48. package/dist/production/src/resolvers/mutation/person/DisablePersonMutationResolver.js.map +1 -1
  49. package/dist/tests/cases/unit/databaseContextRetry.test.d.ts +2 -0
  50. package/dist/tests/cases/unit/databaseContextRetry.test.d.ts.map +1 -0
  51. package/dist/tests/cases/unit/idp/idpManagerUnknownConfigKeys.test.d.ts +2 -0
  52. package/dist/tests/cases/unit/idp/idpManagerUnknownConfigKeys.test.d.ts.map +1 -0
  53. package/dist/tests/tsconfig.tsbuildinfo +1 -1
  54. package/dist/types/model/queries/membership/ProjectMembersQuery.d.ts.map +1 -1
  55. package/dist/types/model/service/PersonAccessManager.d.ts +2 -1
  56. package/dist/types/model/service/PersonAccessManager.d.ts.map +1 -1
  57. package/dist/types/model/service/apiKey/ApiKeyManager.d.ts.map +1 -1
  58. package/dist/types/model/service/idp/IDPManager.d.ts +18 -0
  59. package/dist/types/model/service/idp/IDPManager.d.ts.map +1 -1
  60. package/dist/types/model/utils/DatabaseContext.d.ts +14 -1
  61. package/dist/types/model/utils/DatabaseContext.d.ts.map +1 -1
  62. package/dist/types/tsconfig.tsbuildinfo +1 -1
  63. package/package.json +1 -1
  64. package/src/model/queries/membership/ProjectMembersQuery.ts +1 -0
  65. package/src/model/service/PersonAccessManager.ts +5 -2
  66. package/src/model/service/apiKey/ApiKeyManager.ts +5 -3
  67. package/src/model/service/idp/IDPManager.ts +30 -2
  68. package/src/model/utils/DatabaseContext.ts +27 -6
  69. package/src/resolvers/mutation/person/DisablePersonMutationResolver.ts +1 -1
  70. package/tests/cases/integration/mocked/listProjectMembersQuery.test.ts +8 -3
  71. package/tests/cases/unit/databaseContextRetry.test.ts +80 -0
  72. package/tests/cases/unit/idp/idpManagerUnknownConfigKeys.test.ts +101 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contember/engine-tenant-api",
3
- "version": "2.1.0-rc.1",
3
+ "version": "2.1.0-rc.2",
4
4
  "license": "Apache-2.0",
5
5
  "main": "./dist/production/index.js",
6
6
  "typings": "./dist/types/index.d.ts",
@@ -50,6 +50,7 @@ export class ProjectMembersQuery extends DatabaseQuery<ProjectMembersQueryResult
50
50
 
51
51
  return qb
52
52
  })
53
+ .orderBy(['identity', 'id'])
53
54
  .limit(this.projectMemberInput.limit ?? undefined, this.projectMemberInput.offset ?? undefined)
54
55
  .getResult(db)
55
56
  }
@@ -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
- setImmediate(async () => {
48
- await dbContext.commandBus.execute(
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>(cb: (dbContext: DatabaseContext<Connection.TransactionLike>) => Promise<T>): Promise<T> {
24
- return await this.client.transaction(async db => {
25
- await db.query(Connection.REPEATABLE_READ)
26
- return await cb(new DatabaseContext(db, this.providers))
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: { filter: { email: ["foo@localhost", "bar@localhost"], memberType: PERSON } }
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
- and exists (select ?::int from "tenant"."person" where "person"."identity_id" = "identity"."id" and "email" in (?, ?))`,
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
+ })