Files

855 lines
33 KiB
Go

package control
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gpt-load/internal/platform/config"
"gpt-load/internal/protocol"
"gpt-load/internal/state"
stateloader "gpt-load/internal/state/loader"
"gpt-load/internal/storage/models"
)
func TestCreateAccessKeyGeneratesEncryptedSKGLToken(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
fixture.service.random = bytes.NewReader([]byte{
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07,
0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
})
if err := fixture.registry.ApplyCredentialImport(77, []state.CredentialEntry{{
ID: 88, GroupID: 77, Version: 1, IdentityGeneration: 1,
Fingerprint: "existing-upstream", Status: state.CredentialStatusActive,
EncryptedValue: "existing-upstream-cipher",
}}); err != nil {
t.Fatalf("seed Registry: %v", err)
}
if count, ok := fixture.registry.IncrFailure(88); !ok || count != 1 {
t.Fatalf("seed failure count = %d, %t", count, ok)
}
before := fixture.manager.Current().Revision
result, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{Name: " client "})
if err != nil {
t.Fatalf("CreateAccessKey() error = %v", err)
}
if !strings.HasPrefix(result.Key, "sk-gl-") || len(result.Key) != len("sk-gl-")+32 {
t.Fatalf("generated key = %q, want sk-gl- plus 32 hex chars", result.Key)
}
if _, err := hex.DecodeString(strings.TrimPrefix(result.Key, "sk-gl-")); err != nil {
t.Fatalf("generated suffix is not hex: %v", err)
}
plaintext := result.Key
if result.Name != "client" || result.Status != state.AccessKeyStatusActive {
t.Fatalf("CreateAccessKey() = %#v", result)
}
if got := fixture.manager.Current().Revision; got != before+1 {
t.Fatalf("revision = %d, want %d", got, before+1)
}
var row models.AccessKey
if err := fixture.db.First(&row, result.ID).Error; err != nil {
t.Fatalf("query AccessKey: %v", err)
}
if row.KeyValue == "" || row.KeyHash == "" || row.KeyValue == plaintext || strings.Contains(row.KeyValue, plaintext) {
t.Fatalf("stored AccessKey exposes plaintext: %#v", row)
}
decrypted, err := fixture.encryption.Decrypt(row.KeyValue)
if err != nil || decrypted != plaintext {
t.Fatalf("Decrypt() = %q, %v, want plaintext", decrypted, err)
}
if row.KeyHash != fixture.encryption.Hash(plaintext) {
t.Fatalf("KeyHash = %q, want stable HMAC", row.KeyHash)
}
if _, ok := fixture.manager.Current().AccessKeysByHash[row.KeyHash]; !ok {
t.Fatalf("published snapshot lacks hash %q", row.KeyHash)
}
if got, ok := fixture.registry.EncryptedCredentialData(88); !ok || got != "existing-upstream-cipher" {
t.Fatalf("Registry value = %q, %t, want unchanged", got, ok)
}
if count, ok := fixture.registry.IncrFailure(88); !ok || count != 2 {
t.Fatalf("Registry failure count = %d, %t, want preserved count 2", count, ok)
}
}
func TestAccessKeyFiltersNormalizeAndAcceptExistingDisabledGroups(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
enabled := validControlGroup("filter-enabled")
if err := fixture.db.Create(enabled).Error; err != nil {
t.Fatalf("create enabled group: %v", err)
}
disabled := validControlGroup("filter-disabled")
if err := fixture.db.Create(disabled).Error; err != nil {
t.Fatalf("create disabled group: %v", err)
}
if err := fixture.db.Model(disabled).Update("enabled", false).Error; err != nil {
t.Fatalf("disable group: %v", err)
}
fixture.service.random = bytes.NewReader(make([]byte, 16))
result, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{
Name: "filtered",
Filters: &AccessKeyFilters{
Groups: []uint{enabled.ID, disabled.ID, enabled.ID},
Protocols: []protocol.Protocol{protocol.OpenAICompletions, protocol.Anthropic, protocol.OpenAICompletions},
Models: []string{" gpt-4o ", "gpt-4o", "claude"},
},
})
if err != nil {
t.Fatalf("CreateAccessKey() error = %v", err)
}
if len(result.Filters.Groups) != 2 || result.Filters.Groups[0] != enabled.ID || result.Filters.Groups[1] != disabled.ID {
t.Fatalf("normalized groups = %#v", result.Filters.Groups)
}
if len(result.Filters.Protocols) != 2 || result.Filters.Protocols[0] != protocol.OpenAICompletions || result.Filters.Protocols[1] != protocol.Anthropic {
t.Fatalf("normalized protocols = %#v", result.Filters.Protocols)
}
if len(result.Filters.Models) != 2 || result.Filters.Models[0] != "gpt-4o" || result.Filters.Models[1] != "claude" {
t.Fatalf("normalized models = %#v", result.Filters.Models)
}
var row models.AccessKey
if err := fixture.db.First(&row, result.ID).Error; err != nil {
t.Fatalf("query AccessKey: %v", err)
}
var document map[string]json.RawMessage
if err := json.Unmarshal(row.Filters, &document); err != nil {
t.Fatalf("decode stored filters: %v", err)
}
if len(document) != 3 || document["groups"] == nil || document["protocols"] == nil || document["models"] == nil {
t.Fatalf("stored filter document = %#v", document)
}
view := fixture.manager.Current().AccessKeysByHash[row.KeyHash]
if _, ok := view.Filters.Groups[enabled.ID]; !ok {
t.Fatalf("published filters = %#v, want enabled group", view.Filters)
}
if _, ok := view.Filters.Groups[disabled.ID]; !ok {
t.Fatalf("published filters = %#v, want disabled group reference retained", view.Filters)
}
}
func TestAccessKeyCreateAcceptsAllEnabledProtocolsInCanonicalOrder(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
fixture.service.random = bytes.NewReader(make([]byte, 16))
result, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
Name: "all protocols",
Filters: &AccessKeyFilters{
Protocols: []protocol.Protocol{
protocol.Gemini,
protocol.OpenAIImages,
protocol.OpenAIEmbeddings,
protocol.Rerank,
protocol.OpenAIResponses,
protocol.Anthropic,
protocol.OpenAICompletions,
protocol.OpenAIResponses,
},
},
})
if err != nil {
t.Fatalf("CreateAccessKey() error = %v", err)
}
want := protocol.DataPlaneProtocols()
if !reflect.DeepEqual(result.Filters.Protocols, want) {
t.Fatalf("protocols = %#v, want %#v", result.Filters.Protocols, want)
}
}
func TestAccessKeyFiltersRejectInvalidCurrentInputWithoutPublishing(t *testing.T) {
t.Parallel()
blank := " "
tooLong := strings.Repeat("名", 86)
controlName := "bad\nname"
tests := []struct {
name string
request AccessKeyCreateRequest
}{
{name: "blank name", request: AccessKeyCreateRequest{Name: blank}},
{name: "long name", request: AccessKeyCreateRequest{Name: tooLong}},
{name: "control name", request: AccessKeyCreateRequest{Name: controlName}},
{name: "zero group", request: AccessKeyCreateRequest{Name: "client", Filters: &AccessKeyFilters{Groups: []uint{0}}}},
{name: "missing group", request: AccessKeyCreateRequest{Name: "client", Filters: &AccessKeyFilters{Groups: []uint{999}}}},
{name: "legacy openai protocol", request: AccessKeyCreateRequest{Name: "client", Filters: &AccessKeyFilters{Protocols: []protocol.Protocol{"openai"}}}},
{name: "legacy response protocol", request: AccessKeyCreateRequest{Name: "client", Filters: &AccessKeyFilters{Protocols: []protocol.Protocol{"openai-response"}}}},
{name: "replaced completions protocol", request: AccessKeyCreateRequest{Name: "client", Filters: &AccessKeyFilters{Protocols: []protocol.Protocol{"openai-chat-completions"}}}},
{name: "unknown protocol", request: AccessKeyCreateRequest{Name: "client", Filters: &AccessKeyFilters{Protocols: []protocol.Protocol{"unknown"}}}},
{name: "blank model", request: AccessKeyCreateRequest{Name: "client", Filters: &AccessKeyFilters{Models: []string{" "}}}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fixture := newServiceFixture(t)
fixture.service.random = bytes.NewReader(make([]byte, 16))
before := fixture.manager.Current().Revision
if _, err := fixture.service.CreateAccessKey(context.Background(), test.request); err == nil {
t.Fatal("CreateAccessKey() error = nil")
}
var count int64
if err := fixture.db.Model(&models.AccessKey{}).Count(&count).Error; err != nil {
t.Fatalf("count AccessKeys: %v", err)
}
if count != 0 || fixture.manager.Current().Revision != before {
t.Fatalf("count/revision = %d/%d, want 0/%d", count, fixture.manager.Current().Revision, before)
}
})
}
}
func TestListAccessKeyCollectionReturnsMaskedMetadataWithoutDecrypting(t *testing.T) {
t.Parallel()
initControlI18n(t)
fixture := newServiceFixture(t)
randomBytes := make([]byte, 32)
for index := range randomBytes {
randomBytes[index] = byte(index)
}
fixture.service.random = bytes.NewReader(randomBytes)
first, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{Name: "first"})
if err != nil {
t.Fatalf("create first: %v", err)
}
second, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{Name: "second"})
if err != nil {
t.Fatalf("create second: %v", err)
}
listed, err := fixture.service.ListAccessKeyCollection(
context.Background(),
AccessKeyCollectionQuery{Page: 1, PageSize: 20},
)
if err != nil {
t.Fatalf("ListAccessKeyCollection() error = %v", err)
}
if len(listed.Items) != 2 ||
listed.Items[0].ID != second.ID ||
listed.Items[0].MaskedKey != "sk-gl-****1e1f" ||
listed.Items[1].ID != first.ID ||
listed.Items[1].MaskedKey != "sk-gl-****0e0f" {
t.Fatalf("ListAccessKeyCollection() = %#v", listed)
}
const corruptCiphertext = "known-corrupt-ciphertext"
if err := fixture.db.Model(&models.AccessKey{}).Where("id = ?", second.ID).UpdateColumn("key_value", corruptCiphertext).Error; err != nil {
t.Fatalf("corrupt second ciphertext: %v", err)
}
if afterCorruption, err := fixture.service.ListAccessKeyCollection(
context.Background(),
AccessKeyCollectionQuery{Page: 1, PageSize: 20},
); err != nil ||
!reflect.DeepEqual(afterCorruption.Items, listed.Items) {
t.Fatalf(
"ListAccessKeyCollection() after ciphertext corruption = %#v, %v, want unchanged metadata",
afterCorruption,
err,
)
}
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/access-keys", nil)
request.Header.Set("Authorization", "Bearer test-auth-key")
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("GET access keys = %d %s", recorder.Code, recorder.Body.String())
}
for _, forbidden := range []string{first.Key, corruptCiphertext} {
if strings.Contains(recorder.Body.String(), forbidden) {
t.Fatalf("fail-closed response exposes %q: %s", forbidden, recorder.Body.String())
}
}
}
func TestListAccessKeyCollectionPaginatesBeyondDefaultPageSize(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
rows := make([]models.AccessKey, 21)
for index := range rows {
rows[index] = models.AccessKey{
Name: fmt.Sprintf("legacy-%02d", index),
KeyValue: "ciphertext",
KeyHash: fmt.Sprintf("legacy-key-hash-%02d", index),
KeySuffix: fmt.Sprintf("%04x", index),
Status: string(state.AccessKeyStatusActive),
Filters: models.JSON(`{}`),
}
}
if err := fixture.db.Create(&rows).Error; err != nil {
t.Fatalf("create access keys: %v", err)
}
listed, err := fixture.service.ListAccessKeyCollection(
t.Context(),
AccessKeyCollectionQuery{Page: 2, PageSize: 20},
)
if err != nil {
t.Fatalf("ListAccessKeyCollection() error = %v", err)
}
if listed.Pagination != (AccessKeyCollectionPagination{
Page: 2, PageSize: 20, TotalItems: 21, TotalPages: 2,
}) {
t.Fatalf("pagination = %#v, want second page of 21 items", listed.Pagination)
}
if len(listed.Items) != 1 || listed.Items[0].ID != rows[0].ID {
t.Fatalf("items = %#v, want final item only", listed.Items)
}
}
func TestUpdateAccessKeyPreservesCredentialAcrossPointerPatches(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
group := validControlGroup("access-key-update")
if err := fixture.db.Create(group).Error; err != nil {
t.Fatalf("create group: %v", err)
}
fixture.service.random = bytes.NewReader(make([]byte, 16))
created, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{
Name: "before",
Filters: &AccessKeyFilters{
Groups: []uint{group.ID}, Protocols: []protocol.Protocol{protocol.OpenAICompletions}, Models: []string{"gpt-4o"},
},
})
if err != nil {
t.Fatalf("CreateAccessKey() error = %v", err)
}
original := loadAccessKeyRow(t, fixture.db, created.ID)
name := " after "
updated, err := fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{Name: &name})
if err != nil {
t.Fatalf("name-only UpdateAccessKey() error = %v", err)
}
if updated.Name != "after" ||
updated.Status != state.AccessKeyStatusActive ||
updated.MaskedKey != created.MaskedKey {
t.Fatalf("name-only update = %#v", updated)
}
assertAccessKeyCredentialUnchanged(t, fixture.db, created.ID, original)
status := state.AccessKeyStatusDisabled
updated, err = fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{Status: &status})
if err != nil {
t.Fatalf("status-only UpdateAccessKey() error = %v", err)
}
if updated.Name != "after" || updated.Status != status || updated.Filters.Models[0] != "gpt-4o" {
t.Fatalf("status-only update = %#v", updated)
}
assertAccessKeyCredentialUnchanged(t, fixture.db, created.ID, original)
emptyFilters := AccessKeyFilters{}
updated, err = fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{Filters: &emptyFilters})
if err != nil {
t.Fatalf("clear-filters UpdateAccessKey() error = %v", err)
}
if len(updated.Filters.Groups) != 0 || len(updated.Filters.Protocols) != 0 || len(updated.Filters.Models) != 0 {
t.Fatalf("cleared filters = %#v", updated.Filters)
}
assertAccessKeyCredentialUnchanged(t, fixture.db, created.ID, original)
finalName := "final"
active := state.AccessKeyStatusActive
finalFilters := AccessKeyFilters{
Groups: []uint{group.ID}, Protocols: []protocol.Protocol{protocol.Anthropic}, Models: []string{"claude-3-5"},
}
updated, err = fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{
Name: &finalName, Status: &active, Filters: &finalFilters,
})
if err != nil {
t.Fatalf("all-fields UpdateAccessKey() error = %v", err)
}
if updated.Name != finalName || updated.Status != active || len(updated.Filters.Groups) != 1 || updated.Filters.Protocols[0] != protocol.Anthropic {
t.Fatalf("all-fields update = %#v", updated)
}
assertAccessKeyCredentialUnchanged(t, fixture.db, created.ID, original)
before := fixture.manager.Current().Revision
if _, err := fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{}); err == nil {
t.Fatal("empty UpdateAccessKey() error = nil")
}
if got := fixture.manager.Current().Revision; got != before {
t.Fatalf("revision after empty update = %d, want %d", got, before)
}
}
func TestUpdateAccessKeyStatusAndDeletePublishWithoutMutatingRegistry(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
fixture.service.random = bytes.NewReader(make([]byte, 16))
created, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{Name: "toggle"})
if err != nil {
t.Fatalf("CreateAccessKey() error = %v", err)
}
row := loadAccessKeyRow(t, fixture.db, created.ID)
if err := fixture.registry.ApplyCredentialImport(77, []state.CredentialEntry{{
ID: 88, GroupID: 77, Version: 1, IdentityGeneration: 1,
Fingerprint: "registry", Status: state.CredentialStatusActive, EncryptedValue: "registry-cipher",
}}); err != nil {
t.Fatalf("seed Registry: %v", err)
}
if count, ok := fixture.registry.IncrFailure(88); !ok || count != 1 {
t.Fatalf("seed failure count = %d, %t", count, ok)
}
before := fixture.manager.Current().Revision
disabled := state.AccessKeyStatusDisabled
if _, err := fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{Status: &disabled}); err != nil {
t.Fatalf("disable UpdateAccessKey() error = %v", err)
}
if got := fixture.manager.Current().Revision; got != before+1 {
t.Fatalf("revision after disable = %d, want %d", got, before+1)
}
if _, ok := fixture.manager.Current().AccessKeysByHash[row.KeyHash]; ok {
t.Fatal("disabled AccessKey remains in authentication snapshot")
}
active := state.AccessKeyStatusActive
if _, err := fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{Status: &active}); err != nil {
t.Fatalf("enable UpdateAccessKey() error = %v", err)
}
if _, ok := fixture.manager.Current().AccessKeysByHash[row.KeyHash]; !ok {
t.Fatal("active AccessKey missing from authentication snapshot")
}
invalid := state.AccessKeyStatus("paused")
before = fixture.manager.Current().Revision
if _, err := fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{Status: &invalid}); err == nil {
t.Fatal("invalid status UpdateAccessKey() error = nil")
}
if got := fixture.manager.Current().Revision; got != before {
t.Fatalf("revision after invalid status = %d, want %d", got, before)
}
if err := fixture.service.DeleteAccessKey(context.Background(), created.ID); err != nil {
t.Fatalf("DeleteAccessKey() error = %v", err)
}
if got := fixture.manager.Current().Revision; got != before+1 {
t.Fatalf("revision after delete = %d, want %d", got, before+1)
}
if _, ok := fixture.manager.Current().AccessKeysByHash[row.KeyHash]; ok {
t.Fatal("deleted AccessKey remains in authentication snapshot")
}
var count int64
if err := fixture.db.Model(&models.AccessKey{}).Where("id = ?", created.ID).Count(&count).Error; err != nil || count != 0 {
t.Fatalf("deleted DB row count = %d, error=%v", count, err)
}
before = fixture.manager.Current().Revision
if err := fixture.service.DeleteAccessKey(context.Background(), created.ID); err == nil {
t.Fatal("repeated DeleteAccessKey() error = nil")
}
if _, err := fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{Name: stringPointer("missing")}); err == nil {
t.Fatal("unknown UpdateAccessKey() error = nil")
}
if got := fixture.manager.Current().Revision; got != before {
t.Fatalf("revision after missing mutations = %d, want %d", got, before)
}
if value, ok := fixture.registry.EncryptedCredentialData(88); !ok || value != "registry-cipher" {
t.Fatalf("Registry value = %q, %t, want unchanged", value, ok)
}
if failures, ok := fixture.registry.IncrFailure(88); !ok || failures != 2 {
t.Fatalf("Registry failure count = %d, %t, want preserved count 2", failures, ok)
}
}
func TestUpdateAccessKeyDoesNotReadCredentialCiphertext(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
fixture.service.random = bytes.NewReader(make([]byte, 16))
created, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{Name: "before"})
if err != nil {
t.Fatalf("CreateAccessKey() error = %v", err)
}
if err := fixture.db.Model(&models.AccessKey{}).Where("id = ?", created.ID).UpdateColumn("key_value", "corrupt").Error; err != nil {
t.Fatalf("corrupt AccessKey: %v", err)
}
before := fixture.manager.Current().Revision
if _, err := fixture.service.UpdateAccessKey(
context.Background(),
created.ID,
AccessKeyUpdateRequest{Name: stringPointer("after")},
); err != nil {
t.Fatalf("UpdateAccessKey() error = %v", err)
}
row := loadAccessKeyRow(t, fixture.db, created.ID)
if row.Name != "after" || fixture.manager.Current().Revision != before+1 {
t.Fatalf("update row/revision: name=%q revision=%d", row.Name, fixture.manager.Current().Revision)
}
}
func TestAccessKeyDanglingFiltersDoNotBlockUnrelatedUpdate(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
danglingPlaintext := "gl-dangling-test-value"
danglingCiphertext, err := fixture.encryption.Encrypt(danglingPlaintext)
if err != nil {
t.Fatalf("encrypt dangling key: %v", err)
}
if err := fixture.db.Create(&models.AccessKey{
Name: "legacy", KeyValue: danglingCiphertext, KeyHash: fixture.encryption.Hash(danglingPlaintext),
KeySuffix: "0000", Status: string(state.AccessKeyStatusActive),
Filters: models.JSON(`{"groups":[999],"protocols":[],"models":[]}`),
}).Error; err != nil {
t.Fatalf("create dangling AccessKey: %v", err)
}
fixture.service.random = bytes.NewReader(make([]byte, 16))
current, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{Name: "current"})
if err != nil {
t.Fatalf("create current AccessKey: %v", err)
}
if _, err := fixture.service.UpdateAccessKey(context.Background(), current.ID, AccessKeyUpdateRequest{Name: stringPointer("renamed")}); err != nil {
t.Fatalf("unrelated UpdateAccessKey() error = %v", err)
}
before := fixture.manager.Current().Revision
invalidFilters := AccessKeyFilters{Groups: []uint{999}}
if _, err := fixture.service.UpdateAccessKey(context.Background(), current.ID, AccessKeyUpdateRequest{Filters: &invalidFilters}); err == nil {
t.Fatal("current dangling filter UpdateAccessKey() error = nil")
}
if got := fixture.manager.Current().Revision; got != before {
t.Fatalf("revision after rejected dangling filter = %d, want %d", got, before)
}
}
func TestConcurrentAccessKeyCRUDPublishesDatabaseTruth(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
rows := make([]AccessKeyCreateResult, 4)
for index := range rows {
created, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{Name: "seed-" + string(rune('a'+index))})
if err != nil {
t.Fatalf("seed AccessKey %d: %v", index, err)
}
rows[index] = created
}
before := fixture.manager.Current().Revision
start := make(chan struct{})
errors := make(chan error, 6)
var ready sync.WaitGroup
ready.Add(6)
operations := []func() error{
func() error {
_, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{Name: "concurrent-a"})
return err
},
func() error {
_, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{Name: "concurrent-b"})
return err
},
func() error {
_, err := fixture.service.UpdateAccessKey(context.Background(), rows[0].ID, AccessKeyUpdateRequest{Name: stringPointer("updated-a")})
return err
},
func() error {
status := state.AccessKeyStatusDisabled
_, err := fixture.service.UpdateAccessKey(context.Background(), rows[1].ID, AccessKeyUpdateRequest{Status: &status})
return err
},
func() error { return fixture.service.DeleteAccessKey(context.Background(), rows[2].ID) },
func() error { return fixture.service.DeleteAccessKey(context.Background(), rows[3].ID) },
}
for _, operation := range operations {
operation := operation
go func() {
ready.Done()
<-start
errors <- operation()
}()
}
ready.Wait()
close(start)
for range operations {
if err := <-errors; err != nil {
t.Fatalf("concurrent AccessKey mutation error = %v", err)
}
}
input, err := stateloader.BuildCompileInput(context.Background(), fixture.db)
if err != nil {
t.Fatalf("BuildCompileInput() error = %v", err)
}
want, err := state.Compile(input)
if err != nil {
t.Fatalf("Compile(DB input) error = %v", err)
}
got := fixture.manager.Current()
want.Revision = got.Revision
if !reflect.DeepEqual(got, want) {
t.Fatalf("published snapshot differs from DB compile\ngot=%#v\nwant=%#v", got, want)
}
if got.Revision != before+uint64(len(operations)) {
t.Fatalf("revision = %d, want %d", got.Revision, before+uint64(len(operations)))
}
}
func TestAccessKeyServiceRPMLimit(t *testing.T) {
t.Parallel()
fixture := newServiceFixture(t)
fixture.service.random = bytes.NewReader(make([]byte, 16))
created, err := fixture.service.CreateAccessKey(context.Background(), AccessKeyCreateRequest{
Name: "limited", RPMLimit: OptionalRPMLimit{Set: true, Value: 12},
})
if err != nil {
t.Fatalf("CreateAccessKey() error = %v", err)
}
if created.RPMLimit != 12 {
t.Fatalf("created RPMLimit = %d, want 12", created.RPMLimit)
}
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.RPMLimit != 12 {
t.Fatalf("stored RPMLimit = %d, want 12", row.RPMLimit)
}
if got := fixture.manager.Current().AccessKeysByHash[loadAccessKeyRow(t, fixture.db, created.ID).KeyHash].RPMLimit; got != 12 {
t.Fatalf("snapshot RPMLimit = %d, want 12", got)
}
updated, err := fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{Name: stringPointer("renamed")})
if err != nil {
t.Fatalf("UpdateAccessKey() without rpm_limit error = %v", err)
}
if updated.RPMLimit != 12 {
t.Fatalf("preserved RPMLimit = %d, want 12", updated.RPMLimit)
}
updated, err = fixture.service.UpdateAccessKey(context.Background(), created.ID, AccessKeyUpdateRequest{
RPMLimit: OptionalRPMLimit{Set: true, Value: 0},
})
if err != nil {
t.Fatalf("UpdateAccessKey() with rpm_limit=0 error = %v", err)
}
row := loadAccessKeyRow(t, fixture.db, created.ID)
if updated.RPMLimit != 0 || row.RPMLimit != 0 || fixture.manager.Current().AccessKeysByHash[row.KeyHash].RPMLimit != 0 {
t.Fatalf("updated RPMLimit = %#v, want zero", updated)
}
}
func TestAccessKeyEndpointsDistinguishRPMLimit(t *testing.T) {
t.Parallel()
initControlI18n(t)
const authKey = "test-auth-key"
newServer := func(t *testing.T) (serviceFixture, *gin.Engine) {
t.Helper()
fixture := newServiceFixture(t)
randomBytes := make([]byte, 32)
for index := range randomBytes {
randomBytes[index] = byte(index)
}
fixture.service.random = bytes.NewReader(randomBytes)
engine := gin.New()
NewServer(&config.Config{AuthKey: authKey}, fixture.service).RegisterRoutes(engine)
return fixture, engine
}
serve := func(engine *gin.Engine, method, path, payload string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, path, strings.NewReader(payload))
request.Header.Set("Authorization", "Bearer "+authKey)
request.Header.Set("Content-Type", "application/json")
if method == http.MethodPost && path == "/api/access-keys" {
setRequiredTestIdempotencyHeader(request)
}
engine.ServeHTTP(recorder, request)
return recorder
}
decodeAccessKey := func(t *testing.T, body []byte) AccessKeyCreateResult {
t.Helper()
var envelope struct {
Data AccessKeyCreateResult `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("decode response: %v", err)
}
return envelope.Data
}
for _, test := range []struct {
name string
payload string
wantStatus int
wantLimit int64
}{
{name: "omitted defaults to zero", payload: `{"name":"omitted"}`, wantStatus: http.StatusOK, wantLimit: 0},
{name: "value is persisted", payload: `{"name":"limited","rpm_limit":12}`, wantStatus: http.StatusOK, wantLimit: 12},
{name: "null is rejected", payload: `{"name":"null","rpm_limit":null}`, wantStatus: http.StatusBadRequest},
{name: "negative is rejected", payload: `{"name":"negative","rpm_limit":-1}`, wantStatus: http.StatusBadRequest},
} {
t.Run("POST "+test.name, func(t *testing.T) {
fixture, engine := newServer(t)
beforeRevision := fixture.manager.Current().Revision
recorder := serve(engine, http.MethodPost, "/api/access-keys", test.payload)
if recorder.Code != test.wantStatus {
t.Fatalf("POST = %d %s, want %d", recorder.Code, recorder.Body.String(), test.wantStatus)
}
if test.wantStatus != http.StatusOK {
if !strings.Contains(recorder.Body.String(), "VALIDATION_FAILED") {
t.Fatalf("POST error = %s, want VALIDATION_FAILED", recorder.Body.String())
}
var count int64
if err := fixture.db.Model(&models.AccessKey{}).Count(&count).Error; err != nil {
t.Fatalf("count rows: %v", err)
}
if count != 0 || fixture.manager.Current().Revision != beforeRevision {
t.Fatalf("rows/revision = %d/%d, want 0/%d", count, fixture.manager.Current().Revision, beforeRevision)
}
return
}
response := decodeAccessKey(t, recorder.Body.Bytes())
if response.RPMLimit != test.wantLimit {
t.Fatalf("response RPMLimit = %d, want %d", response.RPMLimit, test.wantLimit)
}
row := loadAccessKeyRow(t, fixture.db, response.ID)
if row.RPMLimit != test.wantLimit || fixture.manager.Current().AccessKeysByHash[row.KeyHash].RPMLimit != test.wantLimit {
t.Fatalf("row/snapshot RPMLimit = %d/%d, want %d", row.RPMLimit, fixture.manager.Current().AccessKeysByHash[row.KeyHash].RPMLimit, test.wantLimit)
}
if strings.Contains(recorder.Body.String(), "daily_cost_limit") || strings.Contains(recorder.Body.String(), "monthly_cost_limit") {
t.Fatalf("response exposes deferred cost limit: %s", recorder.Body.String())
}
})
}
t.Run("PUT omission preserves and zero clears", func(t *testing.T) {
fixture, engine := newServer(t)
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
Name: "seed", RPMLimit: OptionalRPMLimit{Set: true, Value: 12},
})
if err != nil {
t.Fatalf("seed CreateAccessKey() error = %v", err)
}
path := "/api/access-keys/" + strconv.FormatUint(uint64(created.ID), 10)
response := decodeAccessKey(t, serve(engine, http.MethodPut, path, `{"name":"renamed"}`).Body.Bytes())
if response.RPMLimit != 12 || loadAccessKeyRow(t, fixture.db, created.ID).RPMLimit != 12 {
t.Fatalf("omitted PUT did not preserve rpm_limit: %#v", response)
}
response = decodeAccessKey(t, serve(engine, http.MethodPut, path, `{"rpm_limit":0}`).Body.Bytes())
if response.RPMLimit != 0 || loadAccessKeyRow(t, fixture.db, created.ID).RPMLimit != 0 {
t.Fatalf("zero PUT did not clear rpm_limit: %#v", response)
}
})
t.Run("PUT null is rejected without revision", func(t *testing.T) {
fixture, engine := newServer(t)
created, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
Name: "seed", RPMLimit: OptionalRPMLimit{Set: true, Value: 12},
})
if err != nil {
t.Fatalf("seed CreateAccessKey() error = %v", err)
}
beforeRevision := fixture.manager.Current().Revision
path := "/api/access-keys/" + strconv.FormatUint(uint64(created.ID), 10)
recorder := serve(engine, http.MethodPut, path, `{"rpm_limit":null}`)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "VALIDATION_FAILED") {
t.Fatalf("PUT null = %d %s", recorder.Code, recorder.Body.String())
}
if row := loadAccessKeyRow(t, fixture.db, created.ID); row.RPMLimit != 12 {
t.Fatalf("PUT null changed row: %#v", row)
}
if fixture.manager.Current().Revision != beforeRevision {
t.Fatalf("PUT null changed revision to %d, want %d", fixture.manager.Current().Revision, beforeRevision)
}
})
t.Run("GET includes rpm_limit without deferred costs", func(t *testing.T) {
fixture, engine := newServer(t)
for _, limit := range []int64{0, 12} {
if _, err := fixture.service.CreateAccessKey(t.Context(), AccessKeyCreateRequest{
Name: fmt.Sprintf("key-%d", limit), RPMLimit: OptionalRPMLimit{Set: true, Value: limit},
}); err != nil {
t.Fatalf("CreateAccessKey(%d) error = %v", limit, err)
}
}
recorder := serve(engine, http.MethodGet, "/api/access-keys", "")
if recorder.Code != http.StatusOK {
t.Fatalf("GET = %d %s", recorder.Code, recorder.Body.String())
}
var envelope struct {
Data struct {
Items []map[string]json.RawMessage `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode list response: %v", err)
}
if len(envelope.Data.Items) != 2 {
t.Fatalf("list = %#v, want two items", envelope.Data.Items)
}
for _, item := range envelope.Data.Items {
if item["rpm_limit"] == nil || item["daily_cost_limit"] != nil || item["monthly_cost_limit"] != nil {
t.Fatalf("list item fields = %#v", item)
}
}
})
}
func loadAccessKeyRow(t *testing.T, db *gorm.DB, id uint) models.AccessKey {
t.Helper()
var row models.AccessKey
if err := db.First(&row, id).Error; err != nil {
t.Fatalf("query AccessKey %d: %v", id, err)
}
return row
}
func assertAccessKeyCredentialUnchanged(t *testing.T, db *gorm.DB, id uint, original models.AccessKey) {
t.Helper()
current := loadAccessKeyRow(t, db, id)
if current.KeyValue != original.KeyValue || current.KeyHash != original.KeyHash {
t.Fatalf("credential changed: before=%q/%q after=%q/%q", original.KeyValue, original.KeyHash, current.KeyValue, current.KeyHash)
}
}
func stringPointer(value string) *string {
return &value
}
func seedHistoricalAccessKeyWithReservedProtocol(
t *testing.T,
fixture serviceFixture,
protocols []protocol.Protocol,
) (models.AccessKey, string) {
t.Helper()
plaintext := accessKeyPrefix + strings.Repeat("ab", 16)
ciphertext, err := fixture.encryption.Encrypt(plaintext)
if err != nil {
t.Fatalf("encrypt historical AccessKey: %v", err)
}
encodedFilters, err := json.Marshal(AccessKeyFilters{
Groups: []uint{},
Protocols: protocols,
Models: []string{},
})
if err != nil {
t.Fatalf("encode historical AccessKey filters: %v", err)
}
row := models.AccessKey{
Name: "legacy",
KeyValue: ciphertext,
KeyHash: fixture.encryption.Hash(plaintext),
KeySuffix: "abab",
Status: string(state.AccessKeyStatusActive),
Filters: models.JSON(encodedFilters),
}
if _, err := fixture.service.writeConfig(
t.Context(),
func(tx *gorm.DB) error {
return tx.Create(&row).Error
},
nil,
); err != nil {
t.Fatalf("publish historical AccessKey: %v", err)
}
return row, plaintext
}