feat: 增加 OpenBao 应用凭据安全存储切片
Verify / test (pull_request) Successful in 5m43s
Verify / lint (pull_request) Successful in 14m8s
Verify / database-integration (pull_request) Failing after 13m35s

This commit is contained in:
2026-09-25 15:41:53 +00:00
parent f4deb98a7f
commit f6bb9e4599
10 changed files with 810 additions and 18 deletions
@@ -0,0 +1,140 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package openbao 通过官方 SDK 适配应用凭据,不保存资源归属或重建供应状态。
package openbao
import (
"context"
"errors"
"maps"
"net/http"
"regexp"
"slices"
"strings"
bao "github.com/openbao/openbao/api/v2"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
)
var (
ErrInvalidLocation = errors.New("credential location is outside the configured scope")
ErrUnavailable = errors.New("credential backend unavailable")
ErrNotFound = errors.New("application credential not found")
ErrConflict = errors.New("credential creation requires manual conflict resolution")
ErrUncertain = errors.New("credential creation outcome is uncertain; manual resolution required")
)
var pathSegment = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// Credentials 使用独立的 SDK client;认证与短期 token 生命周期由部署装配负责。
// 本适配器既不自动认领已有值,也不提供覆盖、轮换或删除操作。
type Credentials struct {
kv *bao.KVv2
basePath string
}
// NewCredentials 不登录、不读取环境 token。调用方必须提供专用的已认证 client。
// 禁用 SDK 写入重试,防止第一次结果丢失后被 CAS 错误掩盖。
func NewCredentials(client *bao.Client, mount, basePath string) (*Credentials, error) {
if client == nil || !validPath(mount) || !validPath(basePath) {
return nil, ErrInvalidLocation
}
client.SetMaxRetries(0)
return &Credentials{kv: client.KVv2(mount), basePath: basePath}, nil
}
func validPath(value string) bool {
for segment := range strings.SplitSeq(value, "/") {
if !pathSegment.MatchString(segment) || segment == "data" || segment == "metadata" {
return false
}
}
return true
}
// ProvisionPath 只按 Database UID 定位;调用方须先持久化位置,再执行外部写入。
func (c *Credentials) ProvisionPath(databaseUID string) (string, error) {
if !pathSegment.MatchString(databaseUID) {
return "", ErrInvalidLocation
}
return c.basePath + "/" + databaseUID, nil
}
func (c *Credentials) accepts(path string) bool {
return validPath(path) && strings.HasPrefix(path, c.basePath+"/")
}
// Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。
func (c *Credentials) Read(ctx context.Context, path string) (application.ApplicationCredential, error) {
if !c.accepts(path) {
return application.ApplicationCredential{}, ErrInvalidLocation
}
secret, err := c.kv.Get(ctx, path)
if errors.Is(err, bao.ErrSecretNotFound) {
return application.ApplicationCredential{}, ErrNotFound
}
if err != nil {
return application.ApplicationCredential{}, ErrUnavailable
}
if secret == nil || secret.Data == nil {
return application.ApplicationCredential{}, ErrNotFound
}
return application.ParseApplicationCredential(secret.Data)
}
// Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。
// 任何不确定写入都不返回凭据;上层必须停止供应并持久化冲突,不能重新生成密码。
func (c *Credentials) Create(ctx context.Context, path string, credential application.ApplicationCredential) error {
if !c.accepts(path) {
return ErrInvalidLocation
}
if err := credential.Validate(); err != nil {
return err
}
if ctx.Err() != nil {
return ErrUnavailable
}
data := credential.SecretData()
created, err := c.kv.Put(ctx, path, data, bao.WithCheckAndSet(0))
if err != nil {
// 明确的认证/权限拒绝没有发生写入,可以等待依赖恢复。
// SDK 的原始错误可能携带路径及响应体,不向外传播。
if response, ok := errors.AsType[*bao.ResponseError](err); ok {
switch response.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return ErrUnavailable
case http.StatusBadRequest:
if slices.Contains(response.Errors, "check-and-set parameter did not match the current version") {
return ErrConflict
}
}
}
return ErrUncertain
}
if created == nil || created.VersionMetadata == nil || created.VersionMetadata.Version != 1 {
return ErrUncertain
}
observed, err := c.kv.Get(ctx, path)
if err != nil || observed == nil || observed.VersionMetadata == nil || observed.VersionMetadata.Version != 1 {
return ErrUncertain
}
if !maps.Equal(data, observed.Data) {
return ErrUncertain
}
return nil
}
@@ -0,0 +1,187 @@
//go:build integration
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package openbao_test
import (
"context"
"errors"
"maps"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os/exec"
"regexp"
"strings"
"sync"
"testing"
"time"
bao "github.com/openbao/openbao/api/v2"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
)
// 只连接本测试创建的无持久卷 dev server,不接受生产地址或环境 token。
func baoFixture(t *testing.T) *bao.Client {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
const image = "openbao/openbao@sha256:5b2486ab0fb90bbc788cc345b0a08616dfb375873ee8be5df3a2fd4d378a67e0"
output, err := exec.CommandContext(ctx, "docker", "run", "--rm", "-d", "-p", "127.0.0.1::8200",
image, "server", "-dev", "-dev-root-token-id="+fixtureToken, "-dev-listen-address=0.0.0.0:8200").Output()
if err != nil {
t.Fatal("cannot start isolated OpenBao fixture")
}
id := strings.TrimSpace(string(output))
if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) {
t.Fatal("unexpected fixture container ID")
}
t.Cleanup(func() {
cleanup, stop := context.WithTimeout(context.Background(), 30*time.Second)
defer stop()
if exec.CommandContext(cleanup, "docker", "rm", "-f", id).Run() != nil {
t.Error("OpenBao fixture cleanup failed")
}
})
output, err = exec.CommandContext(ctx, "docker", "inspect", "--format",
`{{(index (index .NetworkSettings.Ports "8200/tcp") 0).HostPort}}`, id).Output()
if err != nil {
t.Fatal("cannot inspect fixture port")
}
client := fixtureClient(t, "http://127.0.0.1:"+strings.TrimSpace(string(output)))
client.SetMaxRetries(0)
for {
if _, err := client.Sys().HealthWithContext(ctx); err == nil {
return client
}
select {
case <-ctx.Done():
t.Fatal("OpenBao fixture startup timed out")
case <-time.After(100 * time.Millisecond):
}
}
}
func TestCredentialConcurrentCreateWithRealOpenBao(t *testing.T) {
root := baoFixture(t)
store := fixtureStore(t, root)
credential := fixtureCredential(t)
results := make(chan error, 2)
var workers sync.WaitGroup
for range 2 {
workers.Go(func() { results <- store.Create(t.Context(), credentialPath, credential) })
}
workers.Wait()
close(results)
succeeded, conflicted := 0, 0
for err := range results {
switch err {
case nil:
succeeded++
case openbao.ErrConflict:
conflicted++
default:
t.Fatal("unexpected concurrent create result")
}
}
if succeeded != 1 || conflicted != 1 {
t.Fatal("CAS must allow exactly one creator")
}
}
func TestCredentialLostWriteResponseWithRealOpenBao(t *testing.T) {
root := baoFixture(t)
address, err := url.Parse(root.Address())
if err != nil {
t.Fatal("invalid fixture address")
}
proxy := httputil.NewSingleHostReverseProxy(address)
proxy.ModifyResponse = func(response *http.Response) error {
if response.Request.Method == http.MethodPut && response.StatusCode == http.StatusOK {
return errors.New("fixture drops successful write response")
}
return nil
}
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, _ error) {
w.WriteHeader(http.StatusBadGateway)
}
server := httptest.NewServer(proxy)
defer server.Close()
store := fixtureStore(t, fixtureClient(t, server.URL))
credential := fixtureCredential(t)
if err := store.Create(t.Context(), credentialPath, credential); err != openbao.ErrUncertain {
t.Fatal("lost response must stop provisioning")
}
confirmed, err := root.KVv2("secret").Get(t.Context(), credentialPath)
if err != nil || !maps.Equal(confirmed.Data, credential.SecretData()) || confirmed.VersionMetadata.Version != 1 {
t.Fatal("fault injection did not preserve the original write")
}
if err := fixtureStore(t, root).Create(t.Context(), credentialPath, credential); err != openbao.ErrConflict {
t.Fatal("restart must not adopt an unconfirmed write")
}
}
func TestCredentialsWithRealOpenBao(t *testing.T) {
root := baoFixture(t)
ctx := t.Context()
// root 仅用于 fixture 装配;实际读写使用固定前缀的短期 token。
policy := `path "secret/data/applications/*" { capabilities = ["create", "update", "read"] }`
if err := root.Sys().PutPolicyWithContext(ctx, "application-fixture", policy); err != nil {
t.Fatal("cannot configure fixture policy")
}
secret, err := root.Auth().Token().CreateWithContext(ctx, &bao.TokenCreateRequest{
Policies: []string{"application-fixture"}, NoDefaultPolicy: true, TTL: "5m",
})
if err != nil {
t.Fatal("cannot create scoped fixture token")
}
client := fixtureClient(t, root.Address())
client.SetToken(secret.Auth.ClientToken)
store := fixtureStore(t, client)
credential := fixtureCredential(t)
if err := store.Create(ctx, credentialPath, credential); err != nil {
t.Fatal(err)
}
// 重建适配器读取已确认路径;重复 Create 仍报冲突,不把读取当作认领。
restarted := fixtureStore(t, client)
observed, err := restarted.Read(ctx, credentialPath)
if err != nil || !maps.Equal(observed.SecretData(), credential.SecretData()) {
t.Fatal("confirmed credential was not preserved across adapter restart")
}
if err := restarted.Create(ctx, credentialPath, credential); !errors.Is(err, openbao.ErrConflict) {
t.Fatal("existing credential must conflict even if contents match")
}
metadata, err := root.KVv2("secret").GetMetadata(ctx, credentialPath)
if err != nil || metadata.CurrentVersion != 1 {
t.Fatal("duplicate create changed credential version")
}
if _, err := client.KVv2("secret").Get(ctx, "management/instance"); err == nil {
t.Fatal("scoped token accessed management credentials")
}
if err := root.KVv2("secret").Delete(ctx, credentialPath); err != nil {
t.Fatal("cannot soft-delete fixture credential")
}
if _, err := store.Read(ctx, credentialPath); err != openbao.ErrNotFound {
t.Fatal("soft-deleted credential must not be usable")
}
if err := store.Create(ctx, credentialPath, credential); err != openbao.ErrConflict {
t.Fatal("soft-deleted credential must not be recreated")
}
}
@@ -0,0 +1,190 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package openbao_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
bao "github.com/openbao/openbao/api/v2"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
)
const (
credentialPath = "applications/database-uid"
fixturePassword = "AYATORI-TEST-ONLY-application-password"
fixtureToken = "AYATORI-TEST-ONLY-bao-token"
kvDataKey = "data"
)
func fixtureCredential(t *testing.T) application.ApplicationCredential {
t.Helper()
credential, err := application.ParseApplicationCredential(map[string]any{
"username": "app_owner", "password": fixturePassword, "database": "app",
"host": "postgres.example", "hostaddr": "192.0.2.1", "port": "5432", "sslmode": "verify-full",
})
if err != nil {
t.Fatal(err)
}
return credential
}
func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) {
for _, scenario := range []string{"read failure", "changed version", "changed password", "missing metadata"} {
t.Run(scenario, func(t *testing.T) {
credential := fixtureCredential(t)
var writes atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
writes.Add(1)
var request struct {
Options struct {
CAS *int `json:"cas"`
} `json:"options"`
}
if json.NewDecoder(r.Body).Decode(&request) != nil || request.Options.CAS == nil || *request.Options.CAS != 0 {
t.Error("create request must explicitly require CAS=0")
}
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: map[string]any{"version": 1}}); err != nil {
t.Error("cannot encode fixture write response")
}
return
}
if scenario == "read failure" {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
data := credential.SecretData()
version := 1
if scenario == "changed version" {
version = 2
}
if scenario == "changed password" {
data["password"] = "modified"
}
response := map[string]any{kvDataKey: data}
if scenario != "missing metadata" {
response["metadata"] = map[string]any{"version": version}
}
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: response}); err != nil {
t.Error("cannot encode fixture read response")
}
}))
defer server.Close()
store := fixtureStore(t, fixtureClient(t, server.URL))
if err := store.Create(t.Context(), credentialPath, credential); err != openbao.ErrUncertain || writes.Load() != 1 {
t.Fatal("unconfirmed readback must stop after one write")
}
})
}
}
func fixtureClient(t *testing.T, address string) *bao.Client {
t.Helper()
config := bao.DefaultConfig()
config.Address = address
client, err := bao.NewClient(config)
if err != nil {
t.Fatal("cannot construct fixture client")
}
client.SetToken(fixtureToken)
return client
}
func fixtureStore(t *testing.T, client *bao.Client) *openbao.Credentials {
t.Helper()
store, err := openbao.NewCredentials(client, "secret", "applications")
if err != nil {
t.Fatal(err)
}
return store
}
func TestCredentialLocationScope(t *testing.T) {
client := fixtureClient(t, "http://127.0.0.1:1")
store := fixtureStore(t, client)
path, err := store.ProvisionPath("database-uid")
if err != nil || path != credentialPath {
t.Fatal("unexpected stable location")
}
for _, path := range []string{"", "/absolute", "applications", "applications-other/key", "applications/../management", "applications/%2e%2e/key", "applications//key", "applications/data/key"} {
if _, err := store.Read(t.Context(), path); !errors.Is(err, openbao.ErrInvalidLocation) {
t.Fatal("accepted invalid location")
}
if err := store.Create(t.Context(), path, fixtureCredential(t)); !errors.Is(err, openbao.ErrInvalidLocation) {
t.Fatal("accepted invalid create location")
}
}
for _, uid := range []string{"", "../key", "a/b", "a?b"} {
if _, err := store.ProvisionPath(uid); err == nil {
t.Fatal("accepted invalid UID")
}
}
for _, invalid := range []string{"", "data", "metadata", "../secret", "secret/", "secret?query"} {
if _, err := openbao.NewCredentials(client, invalid, "applications"); err == nil {
t.Fatal("accepted invalid mount")
}
if _, err := openbao.NewCredentials(client, "secret", invalid); err == nil {
t.Fatal("accepted invalid base path")
}
}
}
func TestCredentialWriteFailureDoesNotRetryOrLeak(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
http.Error(w, fixturePassword+fixtureToken, http.StatusInternalServerError)
}))
defer server.Close()
store := fixtureStore(t, fixtureClient(t, server.URL))
if err := store.Create(t.Context(), credentialPath, fixtureCredential(t)); err != openbao.ErrUncertain {
t.Fatal("write error must be a redacted uncertain outcome")
}
if requests.Load() != 1 {
t.Fatal("SDK retried an uncertain write")
}
if _, err := store.Read(t.Context(), credentialPath); err != openbao.ErrUnavailable {
t.Fatal("read error must be redacted")
}
ctx, cancel := context.WithCancel(t.Context())
cancel()
if err := store.Create(ctx, credentialPath, fixtureCredential(t)); err != openbao.ErrUnavailable || requests.Load() != 2 {
t.Fatal("canceled operation must not write")
}
}
func TestCredentialWriteDeniedBeforeExecution(t *testing.T) {
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, fixtureToken, status)
}))
store := fixtureStore(t, fixtureClient(t, server.URL))
err := store.Create(t.Context(), credentialPath, fixtureCredential(t))
server.Close()
if err != openbao.ErrUnavailable {
t.Fatalf("status %d: definite rejection should wait for dependency recovery, got %v", status, err)
}
}
}
@@ -0,0 +1,116 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application
import (
"crypto/rand"
"encoding/base64"
"errors"
"regexp"
"strconv"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
var ErrApplicationCredentialInvalid = errors.New("application credential is invalid")
var applicationIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
// ApplicationCredential 是内存中的应用连接凭据,不得放入 CR 或普通日志。
// 它与 Instance 管理凭据分开,固定输出交付合同中的七键,不生成带密码的 URI。
type ApplicationCredential struct {
username string
password string
database string
endpoint instance.Endpoint
}
func NewApplicationCredential(username, password, database string, endpoint instance.Endpoint) (ApplicationCredential, error) {
if !applicationIdentifier.MatchString(username) || !applicationIdentifier.MatchString(database) || password == "" {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
if endpoint.Validate() != nil {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
return ApplicationCredential{
username: username,
password: password,
database: database,
endpoint: endpoint,
}, nil
}
// GenerateApplicationCredential 仅供已获准首次创建凭据的供应步骤调用。
// 不能在读取失败、写入结果不确定或重启后无条件重新调用。
func GenerateApplicationCredential(username, database string, endpoint instance.Endpoint) (ApplicationCredential, error) {
password := make([]byte, 32)
rand.Read(password)
return NewApplicationCredential(username, base64.RawURLEncoding.EncodeToString(password), database, endpoint)
}
func (c ApplicationCredential) String() string { return "[redacted application credential]" }
func (c ApplicationCredential) GoString() string { return c.String() }
func (c ApplicationCredential) MarshalJSON() ([]byte, error) {
return []byte(`"[redacted application credential]"`), nil
}
// SecretData 只在凭据后端或数据库连接边界使用;返回值包含明文密码,禁止记录日志。
// 每次返回独立 map,调用方不能修改已经构造的凭据。
func (c ApplicationCredential) SecretData() map[string]any {
endpoint := c.endpoint.Values()
return map[string]any{
"username": c.username,
"password": c.password,
"database": c.database,
"host": endpoint.Host,
"hostaddr": endpoint.HostAddr,
"port": strconv.Itoa(endpoint.Port),
"sslmode": string(endpoint.TLSMode),
}
}
func (c ApplicationCredential) Validate() error {
_, err := NewApplicationCredential(c.username, c.password, c.database, c.endpoint)
return err
}
// ParseApplicationCredential 拒绝缺键、非字符串或非法连接参数,不回显后端内容。
func ParseApplicationCredential(data map[string]any) (ApplicationCredential, error) {
values := make(map[string]string, 7)
for _, key := range []string{"username", "password", "database", "host", "hostaddr", "port", "sslmode"} {
value, ok := data[key].(string)
if !ok || value == "" {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
values[key] = value
}
port, err := strconv.Atoi(values["port"])
if err != nil {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: values["host"],
HostAddr: values["hostaddr"],
Port: port,
ManagementDatabase: values["database"],
TLSMode: instance.TLSMode(values["sslmode"]),
})
if err != nil {
return ApplicationCredential{}, ErrApplicationCredentialInvalid
}
return NewApplicationCredential(values["username"], values["password"], values["database"], endpoint)
}
@@ -0,0 +1,81 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package application_test
import (
"encoding/json"
"fmt"
"maps"
"strings"
"testing"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
)
func TestApplicationCredential(t *testing.T) {
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
Host: "postgres.example", HostAddr: "192.0.2.1", Port: 5432,
ManagementDatabase: "postgres", TLSMode: instance.TLSVerifyFull,
})
if err != nil {
t.Fatal(err)
}
first, err := application.GenerateApplicationCredential("owner", "app", endpoint)
if err != nil {
t.Fatal(err)
}
second, err := application.GenerateApplicationCredential("owner", "app", endpoint)
if err != nil {
t.Fatal(err)
}
data := first.SecretData()
if len(data) != 7 || data["password"] == second.SecretData()["password"] || len(data["password"].(string)) != 43 {
t.Fatal("expected seven keys and independent 256-bit passwords")
}
parsed, err := application.ParseApplicationCredential(data)
if err != nil || !maps.Equal(parsed.SecretData(), data) {
t.Fatal("credential did not round trip")
}
encoded, err := json.Marshal(first)
if err != nil {
t.Fatal(err)
}
for _, output := range []string{fmt.Sprint(first), fmt.Sprintf("%+v", first), fmt.Sprintf("%#v", first), string(encoded)} {
if strings.Contains(output, data["password"].(string)) {
t.Fatal("credential formatting leaked the password")
}
}
data["password"] = "changed"
if first.SecretData()["password"] == "changed" {
t.Fatal("caller mutated credential")
}
for key := range data {
invalid := maps.Clone(data)
delete(invalid, key)
if _, err := application.ParseApplicationCredential(invalid); err == nil {
t.Fatalf("accepted missing %s", key)
}
invalid[key] = 42
if _, err := application.ParseApplicationCredential(invalid); err == nil {
t.Fatalf("accepted non-string %s", key)
}
}
if (application.ApplicationCredential{}).Validate() == nil {
t.Fatal("accepted zero credential")
}
}