diff --git a/.changeset/24773-oauth-form-types.md b/.changeset/24773-oauth-form-types.md index 2166d7c6890..9e5294cf0a4 100644 --- a/.changeset/24773-oauth-form-types.md +++ b/.changeset/24773-oauth-form-types.md @@ -10,6 +10,6 @@ Setup › OAuth Applications › Register offered four application types: Web, N - **Redirect URLs help.** It said "Must use https:// in production", which did not tell an admin that a local `http://localhost` callback needs the Native type. It now states the rule for each type: a Web application needs `https://` on a host other than localhost or another loopback address; a Native application may also use `http://` on exactly `localhost`, `127.0.0.1` or `[::1]`, on any port. - **Application Type help.** New: choose Native for a desktop or mobile app, or for an app in local development that redirects to `http://localhost`. -- **Unchanged.** The stored `sys_oauth_application.type` column still lists all four values, so a row written under an earlier provider line keeps its value. The `public` column is unchanged. +- **Unchanged.** A row written under an earlier provider line keeps its stored type, and the `public` column is unchanged; the stored column's options narrow to Web and Native in this release's `sys_oauth_application.type` entry. The zh-CN, ja-JP and es-ES translations carry the new help texts. diff --git a/.changeset/25053-oauth-application-type-options.md b/.changeset/25053-oauth-application-type-options.md new file mode 100644 index 00000000000..3b1b6a9c247 --- /dev/null +++ b/.changeset/25053-oauth-application-type-options.md @@ -0,0 +1,18 @@ +--- +'@objectstack/platform-objects': minor +--- + +fix(platform-objects)!: `sys_oauth_application.type` declares only `web` and `native`, the two application types its writer produces + +Clause-②: no (narrowing) — the stored column's declared options, which `GET /api/v1/meta/object/sys_oauth_application` serves and the record validator judges a write against, lose `useragentbased` and `public`, which no writer produces. + + + +**BREAKING** (a platform object's declared option set narrows), shipped as `minor` under the launch-window convention for breaking changes. + +The column's only writer is the OAuth provider (`@better-auth/oauth-provider`), whose `application_type` plugin-auth maps onto `type`. The provider writes `web` or `native` on every creation and update path, or no type for a client-metadata-document client that names none. The generic data API cannot write the table. The column declared four options, and the string shorthand that declared them strips hyphens, so it served `web`, `native`, `useragentbased` and `public`. No writer produces the last two, and the `user-agent-based` an earlier provider line stored was never one of them. + +- **FROM:** the `type` field of `sys_oauth_application` declared and served the options `web`, `native`, `useragentbased` and `public`. A write that sent `useragentbased` or `public` was accepted and stored. +- **TO:** it declares and serves `web` and `native`. A write that sends any other value, including `useragentbased` and `public`, is refused with `VALIDATION_FAILED` (`400` at an HTTP boundary) and an `invalid_option` finding on `type`. No door sends one: the provider sends only `web`, `native` or no type, and the generic data API answers `405` for every write to this table. +- **Unchanged:** the column stays optional and defaults to `web`, and a `null` type is a blank value. No stored row is rewritten. A row holding any type, `user-agent-based` included, still reads and lists through the data API and the provider. It takes every write that leaves `type` out, such as a secret rotation or enabling and disabling the application. The provider's own update path still refuses a row whose type is not `web` or `native`, as it did before. The register-form entry in this release refers to this entry for the stored column's options. +- **The fix:** nothing to change for a client the provider registered. A row an earlier provider line stored as `user-agent-based` keeps that value. To edit it through the provider's update path, set `application_type` (`web` or `native`) in the same update. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 24bc836ebb0..019a10cebea 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -1389,9 +1389,7 @@ export const enObjects: NonNullable = { label: "Client Type", options: { web: "web", - native: "native", - useragentbased: "user-agent-based", - public: "public" + native: "native" } }, public: { diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 8570706f6d4..39005bfcfc4 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -1389,9 +1389,7 @@ export const esESObjects: NonNullable = { label: "Tipo de cliente", options: { web: "Web", - native: "Nativa", - useragentbased: "Basada en agente de usuario", - public: "Público" + native: "Nativa" } }, public: { diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 11b3aeb53b6..e7d15534368 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -1389,9 +1389,7 @@ export const jaJPObjects: NonNullable = { label: "クライアントタイプ", options: { web: "Web", - native: "ネイティブ", - useragentbased: "ユーザーエージェントベース", - public: "公開" + native: "ネイティブ" } }, public: { diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index c6d8953c417..f9367c48ec6 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -1389,9 +1389,7 @@ export const zhCNObjects: NonNullable = { label: "客户端类型", options: { web: "Web 应用", - native: "原生应用", - useragentbased: "基于用户代理", - public: "公开" + native: "原生应用" } }, public: { diff --git a/packages/platform-objects/src/identity/oauth-register-form-types.test.ts b/packages/platform-objects/src/identity/oauth-register-form-types.test.ts index 9cebb2019a1..6759d975a66 100644 --- a/packages/platform-objects/src/identity/oauth-register-form-types.test.ts +++ b/packages/platform-objects/src/identity/oauth-register-form-types.test.ts @@ -14,10 +14,8 @@ * has neither, so the route registers both as `web`. The form stops offering * them; the route still accepts them, so this is the form only. * - * The stored `type` column is a different surface and keeps all four options: - * its narrowing waits for the major version that refuses the two values at the - * route, so the pin below holds it as it is on purpose (including a declared - * value that differs from the one earlier provider lines wrote — see there). + * The stored `type` column is a different surface. It declares the same two + * values since #25053, and `sys-oauth-application.type-options.test.ts` pins it. * * The help text is not pinned word for word. What is pinned is what it names: * the scheme each type needs and the three native loopback hosts, in the source @@ -105,23 +103,3 @@ describe('[#24773] create_oauth_application — the redirect-URL help states the } }); }); - -describe('[#24773] the stored columns are not narrowed in this major', () => { - it('the stored `type` select still declares all four options, exactly as it is served today', () => { - const stored = (SysOauthApplication.fields as Record).type; - expect(stored?.type).toBe('select'); - const options = (stored?.options ?? []) as { label?: unknown }[]; - expect(options.map((o) => o?.label)).toEqual(['web', 'native', 'user-agent-based', 'public']); - // Measured, not intended: the column is declared through `Field.select`'s - // string-array shorthand, which snake-cases each value and drops the - // hyphens, so the third option's VALUE is `useragentbased`, not the - // `user-agent-based` an earlier provider line wrote. Recorded for the - // major version that narrows this column; this PR changes neither. - expect(optionValues(stored?.options)).toEqual(['web', 'native', 'useragentbased', 'public']); - }); - - it('the stored `public` column is still declared', () => { - const column = (SysOauthApplication.fields as Record).public; - expect(column?.type).toBe('boolean'); - }); -}); diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index dca7b45442d..2f185ac1644 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -156,9 +156,9 @@ export const SysOauthApplication = ObjectSchema.create({ // 400 before the provider runs; no type (absent or null) registers `web`. // Nothing on the server reads these options: the generic action door // refuses a `type: 'api'` action before its param contract runs. The - // stored `type` column below is not narrowed: 1.7.3 writes only `web` or - // `native` (null for a client-metadata-document client), an earlier - // provider line could write `user-agent-based`, and no in-tree reader branches on it. + // stored `type` column below declares the same two values [#25053]: + // 1.7.3 writes only `web` or `native` (null for a client-metadata-document + // client), and a row an earlier provider line stored as `user-agent-based` still reads. params: [ { name: 'name', label: 'Application Name', type: 'text', required: true }, { @@ -446,7 +446,20 @@ export const SysOauthApplication = ObjectSchema.create({ // (#3002). The column keeps its name and its data; the rename is absorbed // by the `applicationType: 'type'` mapping in plugin-auth's // `auth-schema-config.ts`. - type: Field.select(['web', 'native', 'user-agent-based', 'public'], { + // + // [#25053, ADR-0049 enforce-or-remove] The options are exactly the values + // the column's one writer produces. `@better-auth/oauth-provider` 1.7.3 + // writes `application_type` `web` or `native` on every creation and + // update path, and the generic data API cannot write this table + // (`apiMethods` is `get` / `list`). It can also write `null` (a + // client-metadata-document client that names no type), so the column + // stays optional. The former `useragentbased` and `public` options had no + // writer. Rows are not rewritten: a row an earlier provider line stored as + // `user-agent-based` (never a declared value, because the string shorthand + // strips hyphens) still reads and lists, and takes every write that leaves + // `type` out, because the record validator judges only the keys a write + // sends. Neither value has a hyphen, so the shorthand stores each as written. + type: Field.select(['web', 'native'], { label: 'Client Type', required: false, defaultValue: 'web', diff --git a/packages/platform-objects/src/identity/sys-oauth-application.type-options.test.ts b/packages/platform-objects/src/identity/sys-oauth-application.type-options.test.ts new file mode 100644 index 00000000000..c8bdc4da31b --- /dev/null +++ b/packages/platform-objects/src/identity/sys-oauth-application.type-options.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25053] `sys_oauth_application.type` declares exactly the application types + * its one writer produces: `web` and `native`. + * + * The column's only writer is `@better-auth/oauth-provider`, whose + * `applicationType` plugin-auth maps onto `type`. 1.7.3 writes + * `application_type` `web` or `native` on every creation and update path, and + * the generic data API cannot write this table (`apiMethods` is `get` / + * `list`). The column used to declare four options through the string + * shorthand, which strips hyphens, so it served `web`, `native`, + * `useragentbased` and `public`. No writer produces the last two (ADR-0049 + * enforce-or-remove), and the hyphenated `user-agent-based` an earlier + * provider line wrote was never one of them. + * + * What the narrowed declaration does to stored rows is pinned where a real + * engine runs, in plugin-auth's `oauth-application-type-column.test.ts`. This + * package cannot import `@objectstack/objectql`, which depends on it. + */ + +import { describe, expect, it } from 'vitest'; +import { SysOauthApplication } from './index.js'; +import { enObjects } from '../apps/translations/en.objects.generated.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; + +type Option = { label?: unknown; value?: unknown }; +type Column = { type?: string; required?: boolean; defaultValue?: unknown; options?: Option[] }; + +const fields = SysOauthApplication.fields as Record; +const column = fields.type; +const values = (options: unknown[] | undefined): unknown[] => + (options ?? []).map((o) => (typeof o === 'string' ? o : (o as Option)?.value)); + +const LOCALES: readonly (readonly [string, any])[] = [ + ['en', enObjects], + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], +]; + +describe('[#25053] sys_oauth_application.type — the declared options are what the provider writes', () => { + it('declares exactly web and native, each value as written', () => { + expect(column?.type).toBe('select'); + expect(values(column?.options)).toEqual(['web', 'native']); + // The string shorthand keeps the written string as the label and derives + // the value from it; equal pairs say the derivation changed nothing. + for (const option of column?.options ?? []) { + expect(option.value, `option ${String(option.label)}`).toBe(option.label); + } + }); + + it('stays optional, with web as its default', () => { + // The provider can write null (a client-metadata-document client that + // names no type): a required column would make that a missing value. + expect(column?.required).toBe(false); + expect(column?.defaultValue).toBe('web'); + }); + + it('declares the same values the register form offers', () => { + const register = (SysOauthApplication.actions ?? []).find((a) => a.name === 'create_oauth_application'); + const param = ((register?.params ?? []) as { name?: string; options?: unknown[] }[]).find((p) => p.name === 'type'); + expect(values(param?.options)).toEqual(values(column?.options)); + }); + + it('every shipped locale translates exactly those two options', () => { + for (const [locale, bundle] of LOCALES) { + const options = bundle?.sys_oauth_application?.fields?.type?.options; + expect(Object.keys(options ?? {}), `${locale} type options`).toEqual(['web', 'native']); + } + }); + + it('keeps the `public` column', () => { + expect(fields.public?.type).toBe('boolean'); + }); +}); diff --git a/packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts b/packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts new file mode 100644 index 00000000000..41a8e96c415 --- /dev/null +++ b/packages/plugins/plugin-auth/src/oauth-application-type-column.test.ts @@ -0,0 +1,308 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25053] What `sys_oauth_application.type`'s two declared values, `web` and + * `native`, mean for stored rows and for writes. + * + * `platform-objects` declares the column with exactly the values its one + * writer, `@better-auth/oauth-provider`, produces (its own + * `sys-oauth-application.type-options.test.ts` pins the declaration). The + * former `useragentbased` and `public` options had no writer, and a row an + * earlier provider line stored as `user-agent-based` never held a declared + * value. Rows are not rewritten, so this file pins the two halves of the + * narrowing on a real engine: + * + * - A stored row reads, lists and takes every write that leaves `type` out, + * whatever `type` it holds: the record validator judges only the keys a + * write sends. That covers the provider's own secret rotation and the + * enable/disable bridge's `disabled` write. + * - A write that SENDS a value outside the two is refused with + * `VALIDATION_FAILED` (400 at an HTTP boundary) and an `invalid_option` + * finding on `type`, and nothing is written. `null` is a blank value and is + * accepted. No door sends another value today (the provider writes `web`, + * `native` or `null`; the generic data API answers 405 on this table), so + * the refusal guards the declaration rather than a live caller. + * + * Driven against a REAL ObjectQL engine on SQLite with the auth manifest's own + * objects, and REAL better-auth with the OIDC provider on. Rows are seeded and + * read back at driver level, below every engine read mask. Writes go through + * the engine with the system context plugin-auth's adapter writes under. + */ + +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { AuthManager } from './auth-manager'; +import { authIdentityObjects } from './manifest'; + +const SECRET = 'test-secret-at-least-32-chars-long-25053'; +const PASSWORD = 'S3cure!Passw0rd-25053-type'; +const EMAIL = 'owner.type.25053@example.com'; +const ORIGIN = 'http://localhost:3000'; +const CLIENTS = 'sys_oauth_application'; +const SYSTEM = { context: { isSystem: true } }; + +/** Stored values no declaration holds after #25053; the first was never declared. */ +const UNDECLARED = ['user-agent-based', 'useragentbased', 'public'] as const; +/** Every value a stored row may hold: the two declared ones, a blank, and the undeclared ones. */ +const STORED = ['web', 'native', null, ...UNDECLARED] as const; + +/** The three authorization objects a real sign-in reads (as `oauth-register-application-type.test.ts`). */ +const AUTHZ_OBJECTS = [ + { + name: 'sys_user_position', + label: 'User Position', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + position: { name: 'position', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }, + { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }, + { + name: 'sys_position', + label: 'Position', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + label: { name: 'label', type: 'text' as const }, + active: { name: 'active', type: 'boolean' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, + }, +]; + +type Row = Record; + +let engine: ObjectQL; +let manager: AuthManager; +let cookie: string; +let ownerId: string; + +const driver = () => + (engine as unknown as { + getDriver(o: string): { + find(o: string, q: unknown): Promise; + create(o: string, d: Row): Promise; + }; + }).getDriver(CLIENTS); + +/** The stored row, read at DRIVER level. */ +async function stored(id: string): Promise { + const found = await driver().find(CLIENTS, { where: { id } }); + return ((Array.isArray(found) ? found : [found]).filter(Boolean) as Row[])[0]; +} + +const label = (value: string | null) => (value === null ? 'null' : value.replace(/-/g, '_')); + +/** Seed one client row at driver level, below the validator, as an earlier writer left it. */ +async function seed(tag: string, type: string | null): Promise<{ id: string; clientId: string }> { + const id = `oauthc_25053_${tag}`; + const clientId = `c25053-${tag}`; + const now = new Date().toISOString(); + await driver().create(CLIENTS, { + id, + name: `seeded ${tag}`, + client_id: clientId, + client_secret: `digest-${tag}`, + type, + user_id: ownerId, + redirect_uris: JSON.stringify(['https://app.example.com/callback']), + grant_types: JSON.stringify(['authorization_code', 'refresh_token']), + response_types: JSON.stringify(['code']), + token_endpoint_auth_method: 'client_secret_basic', + disabled: false, + created_at: now, + updated_at: now, + }); + expect((await stored(id))?.type, `seeded ${tag}`).toBe(type); + return { id, clientId }; +} + +/** The refusal a write sending an undeclared `type` gets, as an HTTP boundary answers it. */ +async function expectTypeRefused(write: () => Promise) { + const thrown = await write().then( + () => undefined, + (e: unknown) => e, + ); + expect(thrown, 'the write was accepted').toBeDefined(); + const answer = resolveThrownHttpError(thrown); + expect(answer.code).toBe('VALIDATION_FAILED'); + expect(answer.status).toBe(400); + expect(answer.details?.fields).toEqual([expect.objectContaining({ field: 'type', code: 'invalid_option' })]); +} + +beforeAll(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + + engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const object of authIdentityObjects) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + for (const object of AUTHZ_OBJECTS) { + engine.registry.registerObject(object as never, '@objectstack/plugin-security'); + } + await engine.syncSchemas(); + + manager = new AuthManager({ + secret: SECRET, + baseUrl: ORIGIN, + dataEngine: engine as never, + plugins: { oidcProvider: true }, + } as unknown as ConstructorParameters[0]); + + const direct = (path: string, body: unknown) => + manager.handleRequest( + new Request(`${ORIGIN}/api/v1/auth${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', origin: ORIGIN }, + body: JSON.stringify(body), + }), + ); + const signUp = await direct('/sign-up/email', { email: EMAIL, password: PASSWORD, name: 'Client Owner' }); + expect(signUp.status, `sign-up: ${await signUp.clone().text()}`).toBe(200); + const signIn = await direct('/sign-in/email', { email: EMAIL, password: PASSWORD }); + expect(signIn.status, `sign-in: ${await signIn.clone().text()}`).toBe(200); + ownerId = ((await signIn.clone().json()) as { user?: { id?: string } }).user?.id ?? ''; + expect(ownerId, 'sign-in names its user').not.toBe(''); + const staged = signIn.headers.getSetCookie().find((c) => /(?:^|\.)session_token=/.test(c.split(';')[0])); + if (!staged) throw new Error('sign-in staged no session cookie'); + cookie = staged.split(';')[0]; +}, 60_000); + +afterAll(async () => { + vi.restoreAllMocks(); + await (engine as unknown as { destroy?(): Promise })?.destroy?.(); +}); + +describe('[#25053] a stored row reads and lists whatever `type` it holds', () => { + it.each(STORED)('type %s: the record read and the list read return the stored value', async (type) => { + const { id, clientId } = await seed(`read_${label(type)}`, type); + const record = await engine.findOne(CLIENTS, { where: { id }, ...SYSTEM }); + expect(record?.type ?? null).toBe(type); + const listed = (await engine.find(CLIENTS, { where: { client_id: clientId }, ...SYSTEM })) as Row[]; + expect(listed.map((row) => row.type ?? null)).toEqual([type]); + }); + + it.each(STORED)('type %s: the provider reads the client back with its stored type', async (type) => { + const { clientId } = await seed(`provider_read_${label(type)}`, type); + const api = (await manager.getApi()) as unknown as { + getOAuthClient(input: unknown): Promise<{ client_id?: unknown; application_type?: unknown }>; + }; + const client = await api.getOAuthClient({ query: { client_id: clientId }, headers: new Headers({ cookie }) }); + expect(client.client_id).toBe(clientId); + expect(client.application_type ?? null).toBe(type); + }); +}); + +describe('[#25053] a stored row takes every write that leaves `type` out', () => { + it.each(STORED)('type %s: the enable/disable bridge\'s `disabled` write is accepted, the type unchanged', async (type) => { + const { id } = await seed(`disable_${label(type)}`, type); + await engine.update(CLIENTS, { id, disabled: true }, SYSTEM); + const row = await stored(id); + expect(row?.disabled).toBeTruthy(); + expect(row?.type).toBe(type); + }); + + it.each(STORED)('type %s: the provider rotates the client secret, the type unchanged', async (type) => { + const { id, clientId } = await seed(`rotate_${label(type)}`, type); + const api = (await manager.getApi()) as unknown as { + rotateClientSecret(input: unknown): Promise<{ client_secret?: unknown }>; + }; + const rotated = await api.rotateClientSecret({ body: { client_id: clientId }, headers: new Headers({ cookie }) }); + expect(typeof rotated.client_secret).toBe('string'); + const row = await stored(id); + expect(row?.client_secret).not.toBe(`digest-rotate_${label(type)}`); + expect(row?.type).toBe(type); + }); +}); + +describe('[#25053] the provider\'s update path on a row holding an undeclared type', () => { + // The provider re-judges the stored client merged with the update, so it + // refuses an update that leaves such a type in place, before and after the + // narrowing alike; setting `application_type` in the same update is the + // remedy the changeset names, and the engine accepts the value it writes. + type UpdateApi = { updateOAuthClient(input: unknown): Promise<{ application_type?: unknown }> }; + + it.each(UNDECLARED)('type %s: an update that leaves it in place is refused by the provider, the row unchanged', async (type) => { + const { id, clientId } = await seed(`provider_update_${label(type)}`, type); + const api = (await manager.getApi()) as unknown as UpdateApi; + const refusal = await api + .updateOAuthClient({ body: { client_id: clientId, update: { client_name: 'renamed' } }, headers: new Headers({ cookie }) }) + .then(() => undefined, (e: unknown) => e as { body?: { error?: unknown } }); + expect(refusal?.body?.error).toBe('invalid_client_metadata'); + const row = await stored(id); + expect(row?.type).toBe(type); + expect(row?.name).toBe(`seeded provider_update_${label(type)}`); + }); + + it.each(UNDECLARED)('type %s: an update that also sets application_type native is accepted and stored', async (type) => { + const { id, clientId } = await seed(`provider_retype_${label(type)}`, type); + const api = (await manager.getApi()) as unknown as UpdateApi; + const updated = await api.updateOAuthClient({ + body: { client_id: clientId, update: { client_name: 'renamed', application_type: 'native' } }, + headers: new Headers({ cookie }), + }); + expect(updated.application_type).toBe('native'); + expect((await stored(id))?.type).toBe('native'); + }); +}); + +describe('[#25053] a write that sends `type` is judged against web and native', () => { + const insertRow = (tag: string, type: unknown) => ({ + id: `oauthc_25053_${tag}`, + name: `inserted ${tag}`, + client_id: `c25053-${tag}`, + user_id: ownerId, + redirect_uris: JSON.stringify(['https://app.example.com/callback']), + token_endpoint_auth_method: 'client_secret_basic', + type, + }); + + it.each(UNDECLARED)('insert with type %s: refused, nothing stored', async (type) => { + const tag = `insert_${label(type)}`; + await expectTypeRefused(() => engine.insert(CLIENTS, insertRow(tag, type), SYSTEM)); + expect(await stored(`oauthc_25053_${tag}`)).toBeUndefined(); + }); + + it.each(UNDECLARED)('update to type %s: refused, the stored type unchanged', async (type) => { + const { id } = await seed(`update_${label(type)}`, 'web'); + await expectTypeRefused(() => engine.update(CLIENTS, { id, type }, SYSTEM)); + expect((await stored(id))?.type).toBe('web'); + }); + + it.each(['web', 'native'] as const)('insert and update with type %s: accepted and stored as written', async (type) => { + const tag = `insert_${type}`; + await engine.insert(CLIENTS, insertRow(tag, type), SYSTEM); + expect((await stored(`oauthc_25053_${tag}`))?.type).toBe(type); + const { id } = await seed(`update_${type}`, type === 'web' ? 'native' : 'web'); + await engine.update(CLIENTS, { id, type }, SYSTEM); + expect((await stored(id))?.type).toBe(type); + }); + + it('a null type is a blank value: accepted on insert and on update', async () => { + await engine.insert(CLIENTS, insertRow('insert_null', null), SYSTEM); + // The declared default may fill the blank on insert; the value is never refused. + expect(['web', null]).toContain((await stored('oauthc_25053_insert_null'))?.type ?? null); + const { id } = await seed('update_null', 'native'); + await engine.update(CLIENTS, { id, type: null }, SYSTEM); + expect((await stored(id))?.type ?? null).toBeNull(); + }); +});