diff --git a/bonfire/output_test.go b/bonfire/output_test.go index 5e7d5f8..fc80c63 100644 --- a/bonfire/output_test.go +++ b/bonfire/output_test.go @@ -300,6 +300,75 @@ func TestPhase8RedBonfireFlags(t *testing.T) { } } +// TestRepeatableFlagUnsetReturnsEmpty proves an unset Repeatable flag comes +// back as an empty/nil slice, not an error or a panic (D-19 GREEN +// companion to TestPhase8RedBonfireFlags). +func TestRepeatableFlagUnsetReturnsEmpty(t *testing.T) { + var buf bytes.Buffer + var got []string + root, err := NewRootIO("app", []Command{{ + Name: "demo:unset", + Flags: []Flag{ + {Name: "scope", Repeatable: true}, + }, + Run: func(ctx context.Context, in Input, out Output) error { + got = in.Flags("scope") + return nil + }, + }}, strings.NewReader(""), &buf, &buf) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"demo:unset"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("unset repeatable flag = %v, want empty", got) + } +} + +// TestRepeatableFlagCoexistsWithBareAndScalar proves a Repeatable flag, +// a Bare flag and a scalar flag on the same command parse independently +// (D-19 acceptance criteria: existing scalar/bare callers stay unaffected). +func TestRepeatableFlagCoexistsWithBareAndScalar(t *testing.T) { + var buf bytes.Buffer + var gotRedirect []string + var gotList, gotListOK bool + var gotName string + root, err := NewRootIO("app", []Command{{ + Name: "demo:mixed", + Flags: []Flag{ + {Name: "redirect-uri", Repeatable: true}, + {Name: "list", Bare: true}, + {Name: "name", Default: ""}, + }, + Run: func(ctx context.Context, in Input, out Output) error { + gotRedirect = in.Flags("redirect-uri") + listVal, ok := in.Flag("list") + gotList, gotListOK = listVal == "true", ok + gotName, _ = in.Flag("name") + return nil + }, + }}, strings.NewReader(""), &buf, &buf) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{"demo:mixed", "--redirect-uri=https://x.example", "--list", "--name=Catalog"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if !slices.Equal(gotRedirect, []string{"https://x.example"}) { + t.Fatalf("redirect-uri = %v", gotRedirect) + } + if !gotListOK || !gotList { + t.Fatalf("list = (%v, %v), want (true, true)", gotList, gotListOK) + } + if gotName != "Catalog" { + t.Fatalf("name = %q", gotName) + } +} + func TestHelpUsesSharedAdapter(t *testing.T) { var buf bytes.Buffer root, err := NewRoot("hello", []Command{{ diff --git a/bonfire/root.go b/bonfire/root.go index b1c1b64..46bdffc 100644 --- a/bonfire/root.go +++ b/bonfire/root.go @@ -63,6 +63,22 @@ func wrap(c Command, out Output) (*cobra.Command, error) { }, } for _, flag := range c.Flags { + if flag.Repeatable { + // D-19: an ordered, multi-occurrence string flag (Cobra + // StringSlice), read back through Input.Flags. Default is a + // single-element slice only when non-empty, matching the + // scalar path's zero-value convention. + var def []string + if flag.Default != "" { + def = []string{flag.Default} + } + if flag.Shorthand != "" { + cmd.Flags().StringSliceP(flag.Name, flag.Shorthand, def, flag.Description) + } else { + cmd.Flags().StringSlice(flag.Name, def, flag.Description) + } + continue + } if flag.Shorthand != "" { cmd.Flags().StringP(flag.Name, flag.Shorthand, flag.Default, flag.Description) } else { diff --git a/wristband/client_issue.go b/wristband/client_issue.go new file mode 100644 index 0000000..f3aea49 --- /dev/null +++ b/wristband/client_issue.go @@ -0,0 +1,40 @@ +// Shared client-issuing primitives used by both RFC 7591 registration +// (register.go) and the app-owned fonoteka:oauth-client operator command +// (08-CONTEXT.md D-19; T-08-SECRET-TIMING: "Shared hash/validation path and +// one-time secret"). Exporting these rather than letting the command +// re-derive its own random-id/hash/redirect-URI-validation logic keeps +// exactly one code path responsible for how an OAuth client secret is +// generated and hashed. +package wristband + +// IssueClientCredentials mints a random opaque client_id (16 raw bytes, +// base64url) and, for every token_endpoint_auth_method other than "none", a +// client_secret (32 raw bytes) plus its sha256 hex hash -- the identical +// fixed transform RFC 7591 registration uses (D-04). secret is "" and +// secretHash is nil for a public ("none") client. The raw secret is +// returned exactly once; only secretHash is meant to be persisted. +func IssueClientCredentials(authMethod string) (clientID, secret string, secretHash *string, err error) { + clientID, err = randomBase64URL(16) + if err != nil { + return "", "", nil, err + } + if authMethod == "none" { + return clientID, "", nil, nil + } + secret, err = randomBase64URL(32) + if err != nil { + return "", "", nil, err + } + h := sha256Hex(secret) + return clientID, secret, &h, nil +} + +// RejectRedirectURI is the exported form of the redirect-URI validation +// RFC 7591 registration already enforces (PHP OAuthClient::rejectRedirectUri): +// at most 512 characters, a valid URL with scheme+host, https:// or loopback +// http://127.0.0.1 / http://localhost. It returns "" when uri is accepted, +// or a human-readable rejection reason otherwise. The operator command +// shares this exact rule with DCR rather than re-deriving it (D-19). +func RejectRedirectURI(uri string) string { + return rejectRedirectURI(uri) +}