diff --git a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts index 0afeb0e4d2..6f88558830 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.command.unit.test.ts @@ -47,6 +47,23 @@ describe("legacy sso update domain flags (pflag StringSlice parity)", () => { expect(removeDomains).toEqual(["example.com", "example.org"]); }); + test("--domains= (explicit empty value) parses to an empty array, not a missing flag", async () => { + // Backs the "changed vs truthy" mutex-check fix (CLI-1902): the handler's + // `hasExplicitLongFlag` reads raw argv rather than this parsed value + // precisely because `--domains=` collapses to `[]` here, indistinguishable + // from the flag never being passed at all if you only looked at `.length`. + const [, domains] = await Effect.runPromise( + legacySsoUpdateDomainsFlag + .parse({ + flags: { domains: [""] }, + arguments: [], + }) + .pipe(Effect.provide(BunServices.layer)), + ); + + expect(domains).toEqual([]); + }); + test("rejects malformed CSV (bare quote)", async () => { const exit = await Effect.runPromise( legacySsoUpdateDomainsFlag diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index ca86765455..aba1d6833f 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -1,5 +1,5 @@ import type { SupabaseApiError } from "@supabase/api/effect"; -import { Effect, Option, Result } from "effect"; +import { Effect, Option, Result, Stdio } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -7,6 +7,10 @@ import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts" import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; import { LegacyOutputFlag } from "../../../../shared/legacy/global-flags.ts"; +import { + cobraMutuallyExclusiveErrorMessage, + hasExplicitValueFlag, +} from "../../../../shared/cli/cobra-flag-groups.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { encodeGoJson, @@ -48,6 +52,40 @@ const mapGetStatusOrNetwork = mapLegacyHttpError({ statusMessage: (_status, body) => `unexpected error fetching identity provider: ${body}`, }); +const SSO_UPDATE_COMMAND_PATH = ["sso", "update"] as const; + +/** + * Registration order mirrors Go's `cmd/sso.go:178-180` — three independent + * `MarkFlagsMutuallyExclusive` groups (`metadata-file`/`metadata-url` plus two + * 2-element groups sharing `--domains`, not one 3-way group). Cobra checks + * groups in `sort.Strings`-order of the joined group key (`flag_groups.go:189`), + * which happens to match registration order here: "domains add-domains" < + * "domains remove-domains" < "metadata-file metadata-url" alphabetically. + */ +const SSO_UPDATE_MUTEX_GROUPS = [ + ["domains", "add-domains"], + ["domains", "remove-domains"], + ["metadata-file", "metadata-url"], +] as const; + +/** + * Every value-taking (non-boolean) flag `sso update` declares + * (`update.command.ts`) — tells `hasExplicitValueFlag` which bare tokens + * consume the next argv token as their value. `--skip-url-validation` is this + * command's only boolean flag and is deliberately excluded; booleans never + * consume a following token. + */ +const SSO_UPDATE_VALUE_FLAG_NAMES = new Set([ + "project-ref", + "domains", + "add-domains", + "remove-domains", + "metadata-file", + "metadata-url", + "attribute-mapping-file", + "name-id-format", +]); + const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError) => Effect.gen(function* () { const mapped = yield* Effect.flip(mapGetStatusOrNetwork(cause)); @@ -104,34 +142,50 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( const resolver = yield* LegacyProjectRefResolver; const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; + const stdio = yield* Stdio.Stdio; + const rawArgs = yield* stdio.args; yield* Effect.gen(function* () { + // cobra runs `ValidateFlagGroups` (`command.go:1010`) before `RunE` + // (`command.go:1014`), and Go's provider-ID format check lives inside + // `RunE` (`cmd/sso.go:90-91`) — so a mutex violation must win over an + // invalid provider ID when both apply. Keep this block ahead of + // `validateUuid` below to match that precedence. + // + // "Set" follows cobra's `pflag.Changed` — whether the flag was passed at + // all — not the resulting value. `--domains`/`--add-domains`/ + // `--remove-domains` all default to `[]`, so `--domains=` (parses to an + // empty array) must still count as "set"; gating on `.length > 0` would + // miss it, the same "changed vs truthy" gap CLI-1860 fixed for + // `functions download`'s `--use-docker`. + // + // `hasExplicitValueFlag` (not the simpler `hasExplicitLongFlag`) is + // required here because every flag in these groups takes a value: a bare + // `--metadata-file --metadata-url` is pflag consuming `--metadata-url` as + // `metadata-file`'s (oddly named) value, not two flags being set — see + // that function's doc comment. + for (const group of SSO_UPDATE_MUTEX_GROUPS) { + const changed = group.filter((flagName) => + hasExplicitValueFlag( + rawArgs, + SSO_UPDATE_COMMAND_PATH, + SSO_UPDATE_VALUE_FLAG_NAMES, + flagName, + ), + ); + if (changed.length > 1) { + return yield* Effect.fail( + new LegacySsoMutexFlagError({ + message: cobraMutuallyExclusiveErrorMessage(group, changed), + }), + ); + } + } + const providerId = yield* validateUuid(flags.providerId).pipe( Result.match({ onFailure: Effect.fail, onSuccess: Effect.succeed }), ); - if (flags.domains.length > 0 && flags.addDomains.length > 0) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --domains or --add-domains may be set", - }), - ); - } - if (flags.domains.length > 0 && flags.removeDomains.length > 0) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --domains or --remove-domains may be set", - }), - ); - } - if (Option.isSome(flags.metadataFile) && Option.isSome(flags.metadataUrl)) { - return yield* Effect.fail( - new LegacySsoMutexFlagError({ - message: "only one of --metadata-file or --metadata-url may be set", - }), - ); - } - const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts index c1060092cb..c76206a29b 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.integration.test.ts @@ -2,7 +2,7 @@ import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Option } from "effect"; +import { Effect, Exit, Layer, Option, Stdio } from "effect"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts"; @@ -45,6 +45,13 @@ interface SetupOpts { putStatus?: number; putBody?: unknown; upgradeGate?: "gated" | "notGated"; + /** + * Raw argv the handler sees via `Stdio.Stdio` — drives the + * `hasExplicitLongFlag`-based mutex checks. Defaults to a bare invocation + * with none of the mutually-exclusive domain flags present; tests that + * exercise those checks must pass the matching flags explicitly here. + */ + cliArgs?: ReadonlyArray; } function jsonResponse( @@ -122,15 +129,20 @@ function setup(opts: SetupOpts = {}) { }); const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current }); - const layer = buildLegacyTestRuntime({ - out, - api: { layer: api.layer, httpClientLayer: api.httpClientLayer }, - cliConfig, - telemetry: telemetry.layer, - linkedProjectCache: cache.layer, - analytics, - goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), - }); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api: { layer: api.layer, httpClientLayer: api.httpClientLayer }, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: cache.layer, + analytics, + goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + }), + Stdio.layerTest({ + args: Effect.succeed(opts.cliArgs ?? ["sso", "update", VALID_PROVIDER_ID]), + }), + ); return { layer, out, api, analytics, telemetry, cache }; } @@ -200,40 +212,237 @@ describe("legacy sso update integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --domains + --add-domains fails", () => { - const { layer } = setup(); + it.live("mutex check: --domains + --add-domains fails with cobra's exact error text", () => { + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains", "a.com", "--add-domains", "b.com"], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, domains: ["a.com"], addDomains: ["b.com"] }), ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain("LegacySsoMutexFlagError"); + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + // Byte-matches cobra's `validateExclusiveFlagGroups` template + // (`flag_groups.go:204`): group in registration order, changed flags + // sorted alphabetically — "add-domains" < "domains". + expect(dump).toContain( + "if any flags in the group [domains add-domains] are set none of the others can be; [add-domains domains] were all set", + ); } }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --domains + --remove-domains fails", () => { - const { layer } = setup(); + it.live("mutex check: --domains + --remove-domains fails with cobra's exact error text", () => { + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--domains", + "a.com", + "--remove-domains", + "b.com", + ], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, domains: ["a.com"], removeDomains: ["b.com"] }), ); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + expect(dump).toContain( + "if any flags in the group [domains remove-domains] are set none of the others can be; [domains remove-domains] were all set", + ); + } }).pipe(Effect.provide(layer)); }); - it.live("mutex check: --metadata-file + --metadata-url fails", () => { - const { layer } = setup(); + it.live( + "mutex check: an explicit but empty --domains= still conflicts with --add-domains (changed, not truthy)", + () => { + // `--domains=` parses to an empty array, but cobra's `pflag.Changed` + // tracks that the flag was passed at all, not the resulting value — the + // same "changed vs truthy" gap CLI-1860 fixed for `functions download`'s + // `--use-docker`. Gating on `.length > 0` would miss this combination. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains=", "--add-domains", "b.com"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ ...defaultFlags, domains: [], addDomains: ["b.com"] }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacySsoMutexFlagError"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "mutex check: --add-domains and --remove-domains together are not mutually exclusive", + () => { + // Go only registers ("domains","add-domains") and ("domains","remove-domains") + // as separate 2-element groups (`cmd/sso.go:179-180`) — add-domains and + // remove-domains together, without --domains, is not a violation. + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--add-domains", + "b.com", + "--remove-domains", + "c.com", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ ...defaultFlags, addDomains: ["b.com"], removeDomains: ["c.com"] }), + ); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("mutex check: all three domain flags set reports the --add-domains group first", () => { + // Pins the `SSO_UPDATE_MUTEX_GROUPS` array order: cobra's sorted-key + // iteration ("domains add-domains" < "domains remove-domains") means the + // add-domains group is checked — and its error returned — first when all + // three domain flags collide at once. + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--domains", + "a.com", + "--add-domains", + "b.com", + "--remove-domains", + "c.com", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ + ...defaultFlags, + domains: ["a.com"], + addDomains: ["b.com"], + removeDomains: ["c.com"], + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain( + "if any flags in the group [domains add-domains] are set none of the others can be; [add-domains domains] were all set", + ); + expect(dump).not.toContain("remove-domains"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("mutex check: a flag-group violation wins over an invalid provider ID", () => { + // Cobra runs `ValidateFlagGroups` before `RunE` (`command.go:1010,1014`); + // Go's provider-ID format check lives inside `RunE` (`cmd/sso.go:90-91`). + // So an invalid UUID combined with a mutex violation must surface the + // mutex error, not `LegacySsoInvalidUuidError`. + const { layer } = setup({ + cliArgs: ["sso", "update", "not-a-uuid", "--domains", "a.com", "--add-domains", "b.com"], + }); return Effect.gen(function* () { const exit = yield* Effect.exit( legacySsoUpdate({ ...defaultFlags, - metadataFile: Option.some("/tmp/x.xml"), - metadataUrl: Option.some("https://idp.example.com/m"), + providerId: "not-a-uuid", + domains: ["a.com"], + addDomains: ["b.com"], }), ); expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + expect(dump).not.toContain("LegacySsoInvalidUuidError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "mutex check: --metadata-file + --metadata-url fails with cobra's exact error text", + () => { + const { layer } = setup({ + cliArgs: [ + "sso", + "update", + VALID_PROVIDER_ID, + "--metadata-file", + "/tmp/x.xml", + "--metadata-url", + "https://idp.example.com/m", + ], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + legacySsoUpdate({ + ...defaultFlags, + metadataFile: Option.some("/tmp/x.xml"), + metadataUrl: Option.some("https://idp.example.com/m"), + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = JSON.stringify(exit.cause); + expect(dump).toContain("LegacySsoMutexFlagError"); + // Go registers this pair too (`cmd/sso.go:178`) — it was left emitting + // a hand-written message alongside the domains groups' custom text + // before this fix; now all three of `sso update`'s mutex groups on + // this command share the same byte-exact cobra template. + expect(dump).toContain( + "if any flags in the group [metadata-file metadata-url] are set none of the others can be; [metadata-file metadata-url] were all set", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "mutex check: a bare --metadata-file followed by --metadata-url is not a violation", + () => { + // pflag's `--flag arg` branch consumes the very next argv token as the + // value unconditionally (`flag.go:1013-1031`), so real cobra parses this + // as `metadata-file` receiving the literal value `"--metadata-url"` — + // `metadata-url` is never parsed as its own flag and stays unset. The + // TS CLI's own parser (unlike pflag) never hands a dash-prefixed token + // to a non-boolean flag as a bare value, so here both flags resolve to + // `Option.none()` — but the raw-argv mutex scan must reach the same + // "not a violation" conclusion pflag does, not double-count the + // `--metadata-url` token as a second explicit flag. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--metadata-file", "--metadata-url"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); + expect(Exit.isSuccess(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("mutex check: a bare --add-domains followed by --domains=... is not a violation", () => { + // Same consumed-value class as the metadata-file/metadata-url case + // above, but for the domains group: pflag would hand `add-domains` the + // literal value `"--domains=x.com"` and never parse `--domains` at all. + const { layer } = setup({ + cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--add-domains", "--domains=x.com"], + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacySsoUpdate(defaultFlags)); + expect(Exit.isSuccess(exit)).toBe(true); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index 56a58da563..bb3e20716a 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -28,6 +28,59 @@ export function hasExplicitLongFlag( return false; } +/** + * Like `hasExplicitLongFlag`, but aware that a bare (`=`-less) occurrence of a + * *value-taking* flag consumes the very next argv token as its value — + * matching pflag's `parseLongArg` (`flag.go:1013-1031`), which takes the next + * raw arg unconditionally once a long flag needs a value, with no check that + * the token looks like another flag. + * + * Without this, scanning independently per flag name (as `hasExplicitLongFlag` + * does) can mistake a consumed value for a literal occurrence of a sibling + * mutex flag: `--metadata-file --metadata-url` is pflag's `metadata-file` + * flag being handed the (oddly named, but valid) string value + * `"--metadata-url"` — cobra never parses `--metadata-url` as its own flag, + * so `metadata-url.Changed` stays `false`. A naive scan sees both tokens and + * wrongly reports both as set. + * + * `valueFlagNames` must list every value-taking (non-boolean) flag declared + * on the command being scanned, so the scan knows which bare tokens consume a + * following value; boolean flags never consume one and must be omitted. This + * only covers flags local to the command — a global/inherited value-taking + * flag immediately preceding a mutex flag without `=` can still be misread + * the same way; closing that fully would mean teaching this scan about every + * flag reachable at parse time, not just the command's own, which is a + * bigger, cross-cutting change. + */ +export function hasExplicitValueFlag( + rawArgs: ReadonlyArray, + commandPath: ReadonlyArray, + valueFlagNames: ReadonlySet, + flagName: string, +): boolean { + const commandIndex = rawArgs.findIndex((_, index) => + commandPath.every((segment, offset) => rawArgs[index + offset] === segment), + ); + const scoped = commandIndex !== -1; + const tokens = scoped ? rawArgs.slice(commandIndex + commandPath.length) : rawArgs; + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token === undefined || (scoped && token === "--")) { + return false; + } + if (token === `--${flagName}` || token.startsWith(`--${flagName}=`)) { + return true; + } + if (token.startsWith("--") && !token.includes("=") && valueFlagNames.has(token.slice(2))) { + // Bare occurrence of a value-taking flag — skip the token it consumes + // so it can't be mistaken for a literal occurrence of `flagName`. + index += 1; + } + } + return false; +} + /** * Byte-matches cobra's `validateExclusiveFlagGroups` error * (`flag_groups.go:204`): `group` is the full mutually-exclusive set in diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts index d1318cf4c2..2a410121da 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; -import { cobraMutuallyExclusiveErrorMessage, hasExplicitLongFlag } from "./cobra-flag-groups.ts"; +import { + cobraMutuallyExclusiveErrorMessage, + hasExplicitLongFlag, + hasExplicitValueFlag, +} from "./cobra-flag-groups.ts"; const COMMAND_PATH = ["functions", "deploy"] as const; @@ -44,6 +48,88 @@ describe("hasExplicitLongFlag", () => { }); }); +describe("hasExplicitValueFlag", () => { + const SSO_UPDATE_PATH = ["sso", "update"] as const; + const VALUE_FLAGS = new Set(["metadata-file", "metadata-url", "domains", "add-domains"]); + + test("finds a bare flag after the command path", () => { + expect( + hasExplicitValueFlag( + ["sso", "update", "id", "--metadata-file", "foo.xml"], + SSO_UPDATE_PATH, + VALUE_FLAGS, + "metadata-file", + ), + ).toBe(true); + }); + + test("finds a flag with an inline value", () => { + expect( + hasExplicitValueFlag( + ["sso", "update", "id", "--domains=a.com"], + SSO_UPDATE_PATH, + VALUE_FLAGS, + "domains", + ), + ).toBe(true); + }); + + test("does not mistake a value-taking flag's consumed value for a sibling flag", () => { + // pflag's `--flag arg` branch consumes the next token unconditionally + // (`flag.go:1013-1031`), so `--metadata-file --metadata-url` gives + // `metadata-file` the literal value `"--metadata-url"` and never parses + // `--metadata-url` as its own flag. + const args = ["sso", "update", "id", "--metadata-file", "--metadata-url"]; + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-file")).toBe(true); + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-url")).toBe(false); + }); + + test("does not mistake a value-taking flag's consumed value for a sibling flag, reversed", () => { + const args = ["sso", "update", "id", "--metadata-url", "--metadata-file"]; + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-url")).toBe(true); + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-file")).toBe(false); + }); + + test("an inline (`=`) value is never treated as consuming the next token", () => { + // `--metadata-file=--metadata-url` is one token: metadata-file's value is + // the literal string "--metadata-url", and no token is consumed after it. + const args = ["sso", "update", "id", "--metadata-file=--metadata-url", "--domains", "a.com"]; + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-file")).toBe(true); + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-url")).toBe(false); + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "domains")).toBe(true); + }); + + test("a real, non-adjacent occurrence of both flags is still detected", () => { + const args = ["sso", "update", "id", "--metadata-file", "foo.xml", "--metadata-url", "url"]; + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-file")).toBe(true); + expect(hasExplicitValueFlag(args, SSO_UPDATE_PATH, VALUE_FLAGS, "metadata-url")).toBe(true); + }); + + test("returns false when the flag is absent", () => { + expect( + hasExplicitValueFlag(["sso", "update", "id"], SSO_UPDATE_PATH, VALUE_FLAGS, "domains"), + ).toBe(false); + }); + + test("stops scanning at a -- terminator", () => { + expect( + hasExplicitValueFlag( + ["sso", "update", "id", "--", "--domains"], + SSO_UPDATE_PATH, + VALUE_FLAGS, + "domains", + ), + ).toBe(false); + }); + + test("falls back to a bare scan when the command path is not found", () => { + expect(hasExplicitValueFlag(["--domains"], SSO_UPDATE_PATH, VALUE_FLAGS, "domains")).toBe(true); + expect(hasExplicitValueFlag(["--metadata-file"], SSO_UPDATE_PATH, VALUE_FLAGS, "domains")).toBe( + false, + ); + }); +}); + describe("cobraMutuallyExclusiveErrorMessage", () => { test("byte-matches cobra's validateExclusiveFlagGroups template", () => { expect(