316 lines
8.7 KiB
Go
316 lines
8.7 KiB
Go
package apikeys_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"update_server/internal/apikeys"
|
|
"update_server/internal/db"
|
|
)
|
|
|
|
func TestServiceCreateAuthenticateAndRejectInactiveOrExpiredKeys(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
service, store := newAPIKeyTestService(t)
|
|
ctx := context.Background()
|
|
|
|
project := createProject(t, ctx, store, "Desktop App", "desktop-app")
|
|
|
|
created, err := service.Create(ctx, apikeys.CreateParams{
|
|
Name: "Desktop Clients",
|
|
ScopeMode: db.ScopeModeProjectAllowList,
|
|
CanDownload: true,
|
|
ProjectIDs: []int64{project.ID},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create api key: %v", err)
|
|
}
|
|
|
|
stored, err := store.APIKeys.GetByID(ctx, created.APIKey.ID)
|
|
if err != nil {
|
|
t.Fatalf("load stored api key: %v", err)
|
|
}
|
|
|
|
if stored.KeyHash == created.RawKey {
|
|
t.Fatal("expected raw api key to be hashed before storage")
|
|
}
|
|
|
|
if stored.KeyPrefix == created.RawKey {
|
|
t.Fatal("expected only a short key prefix to be stored")
|
|
}
|
|
|
|
authState, err := service.Authenticate(ctx, created.RawKey)
|
|
if err != nil {
|
|
t.Fatalf("authenticate api key: %v", err)
|
|
}
|
|
|
|
if authState.APIKey.ID != created.APIKey.ID {
|
|
t.Fatalf("expected authenticated api key id %d, got %d", created.APIKey.ID, authState.APIKey.ID)
|
|
}
|
|
|
|
stored, err = store.APIKeys.GetByID(ctx, created.APIKey.ID)
|
|
if err != nil {
|
|
t.Fatalf("reload stored api key: %v", err)
|
|
}
|
|
|
|
if stored.LastUsedAt == nil {
|
|
t.Fatal("expected successful authentication to update last_used_at")
|
|
}
|
|
|
|
expiredAt := time.Now().UTC().Add(-time.Hour)
|
|
expired, err := service.Create(ctx, apikeys.CreateParams{
|
|
Name: "Expired Clients",
|
|
ScopeMode: db.ScopeModeAllProjects,
|
|
CanDownload: true,
|
|
ExpiresAt: &expiredAt,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create expired api key: %v", err)
|
|
}
|
|
|
|
if _, err := service.Authenticate(ctx, expired.RawKey); !errors.Is(err, apikeys.ErrUnauthenticated) {
|
|
t.Fatalf("expected expired api key to be rejected, got %v", err)
|
|
}
|
|
|
|
revoked, err := service.Create(ctx, apikeys.CreateParams{
|
|
Name: "Revoked Clients",
|
|
ScopeMode: db.ScopeModeAllProjects,
|
|
CanDownload: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create revoked api key: %v", err)
|
|
}
|
|
|
|
if err := service.SetActive(ctx, revoked.APIKey.ID, false); err != nil {
|
|
t.Fatalf("revoke api key: %v", err)
|
|
}
|
|
|
|
if _, err := service.Authenticate(ctx, revoked.RawKey); !errors.Is(err, apikeys.ErrUnauthenticated) {
|
|
t.Fatalf("expected revoked api key to be rejected, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceListAccessibleProjectsByScopeMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
service, store := newAPIKeyTestService(t)
|
|
ctx := context.Background()
|
|
|
|
desktop := createProject(t, ctx, store, "Desktop App", "desktop-app")
|
|
mobile := createProject(t, ctx, store, "Mobile App", "mobile-app")
|
|
internal := createProject(t, ctx, store, "Internal App", "internal-app")
|
|
archived := createProject(t, ctx, store, "Archived App", "archived-app")
|
|
|
|
windows := createTag(t, ctx, store, "Windows", "windows")
|
|
beta := createTag(t, ctx, store, "Beta", "beta")
|
|
|
|
attachTag(t, ctx, store, desktop.ID, windows.ID)
|
|
attachTag(t, ctx, store, mobile.ID, beta.ID)
|
|
attachTag(t, ctx, store, archived.ID, windows.ID)
|
|
|
|
if err := store.Projects.SetActive(ctx, archived.ID, false); err != nil {
|
|
t.Fatalf("archive project: %v", err)
|
|
}
|
|
|
|
allProjectsKey, err := service.Create(ctx, apikeys.CreateParams{
|
|
Name: "All Projects",
|
|
ScopeMode: db.ScopeModeAllProjects,
|
|
CanDownload: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create all projects key: %v", err)
|
|
}
|
|
|
|
projectAllowKey, err := service.Create(ctx, apikeys.CreateParams{
|
|
Name: "Project Allow",
|
|
ScopeMode: db.ScopeModeProjectAllowList,
|
|
CanDownload: true,
|
|
ProjectIDs: []int64{mobile.ID, internal.ID},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create project allow key: %v", err)
|
|
}
|
|
|
|
projectDenyKey, err := service.Create(ctx, apikeys.CreateParams{
|
|
Name: "Project Deny",
|
|
ScopeMode: db.ScopeModeProjectDenyList,
|
|
CanDownload: true,
|
|
ProjectIDs: []int64{mobile.ID},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create project deny key: %v", err)
|
|
}
|
|
|
|
tagAllowKey, err := service.Create(ctx, apikeys.CreateParams{
|
|
Name: "Tag Allow",
|
|
ScopeMode: db.ScopeModeTagAllowList,
|
|
CanDownload: true,
|
|
TagIDs: []int64{windows.ID},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create tag allow key: %v", err)
|
|
}
|
|
|
|
tagDenyKey, err := service.Create(ctx, apikeys.CreateParams{
|
|
Name: "Tag Deny",
|
|
ScopeMode: db.ScopeModeTagDenyList,
|
|
CanDownload: true,
|
|
TagIDs: []int64{windows.ID},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create tag deny key: %v", err)
|
|
}
|
|
|
|
assertAccessibleProjects(t, ctx, service, *allProjectsKey.APIKey, []string{"Desktop App", "Internal App", "Mobile App"})
|
|
assertAccessibleProjects(t, ctx, service, *projectAllowKey.APIKey, []string{"Internal App", "Mobile App"})
|
|
assertAccessibleProjects(t, ctx, service, *projectDenyKey.APIKey, []string{"Desktop App", "Internal App"})
|
|
assertAccessibleProjects(t, ctx, service, *tagAllowKey.APIKey, []string{"Desktop App"})
|
|
assertAccessibleProjects(t, ctx, service, *tagDenyKey.APIKey, []string{"Internal App", "Mobile App"})
|
|
}
|
|
|
|
func TestServiceUpdateTransitionsScopeRules(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
service, store := newAPIKeyTestService(t)
|
|
ctx := context.Background()
|
|
|
|
project := createProject(t, ctx, store, "Desktop App", "desktop-app")
|
|
otherProject := createProject(t, ctx, store, "Mobile App", "mobile-app")
|
|
windows := createTag(t, ctx, store, "Windows", "windows")
|
|
attachTag(t, ctx, store, project.ID, windows.ID)
|
|
|
|
created, err := service.Create(ctx, apikeys.CreateParams{
|
|
Name: "Transition Key",
|
|
ScopeMode: db.ScopeModeProjectAllowList,
|
|
CanDownload: true,
|
|
ProjectIDs: []int64{otherProject.ID},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create api key: %v", err)
|
|
}
|
|
|
|
updated, err := service.Update(ctx, created.APIKey.ID, apikeys.UpdateParams{
|
|
Name: "Transition Key",
|
|
ScopeMode: db.ScopeModeTagAllowList,
|
|
CanDownload: true,
|
|
TagIDs: []int64{windows.ID},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update api key scope: %v", err)
|
|
}
|
|
|
|
projectAccess, err := service.ListProjectAccess(ctx, updated.ID)
|
|
if err != nil {
|
|
t.Fatalf("list project access: %v", err)
|
|
}
|
|
|
|
if len(projectAccess) != 0 {
|
|
t.Fatalf("expected project access rows to be cleared, got %d", len(projectAccess))
|
|
}
|
|
|
|
tagAccess, err := service.ListTagAccess(ctx, updated.ID)
|
|
if err != nil {
|
|
t.Fatalf("list tag access: %v", err)
|
|
}
|
|
|
|
if len(tagAccess) != 1 || tagAccess[0].ID != windows.ID {
|
|
t.Fatalf("expected one retained tag access row, got %+v", tagAccess)
|
|
}
|
|
|
|
assertAccessibleProjects(t, ctx, service, *updated, []string{"Desktop App"})
|
|
}
|
|
|
|
func assertAccessibleProjects(t *testing.T, ctx context.Context, service *apikeys.Service, key db.APIKey, want []string) {
|
|
t.Helper()
|
|
|
|
projects, err := service.ListAccessibleProjects(ctx, key)
|
|
if err != nil {
|
|
t.Fatalf("list accessible projects: %v", err)
|
|
}
|
|
|
|
got := make([]string, 0, len(projects))
|
|
for _, project := range projects {
|
|
got = append(got, project.Name)
|
|
}
|
|
|
|
if len(got) != len(want) {
|
|
t.Fatalf("expected accessible projects %v, got %v", want, got)
|
|
}
|
|
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("expected accessible projects %v, got %v", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newAPIKeyTestService(t *testing.T) (*apikeys.Service, *db.Store) {
|
|
t.Helper()
|
|
|
|
ctx := context.Background()
|
|
sqlitePath := filepath.Join(t.TempDir(), "apikeys.sqlite")
|
|
|
|
database, err := db.Open(ctx, sqlitePath)
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
|
|
if err := db.Migrate(ctx, database, apiKeyProjectPath(t, "migrations")); err != nil {
|
|
_ = database.Close()
|
|
t.Fatalf("migrate sqlite: %v", err)
|
|
}
|
|
|
|
store := db.NewStore(database)
|
|
t.Cleanup(func() {
|
|
_ = store.Close()
|
|
})
|
|
|
|
return apikeys.NewService(store), store
|
|
}
|
|
|
|
func apiKeyProjectPath(t *testing.T, parts ...string) string {
|
|
t.Helper()
|
|
|
|
_, filename, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("resolve caller path")
|
|
}
|
|
|
|
root := filepath.Join(filepath.Dir(filename), "..", "..")
|
|
items := append([]string{root}, parts...)
|
|
return filepath.Join(items...)
|
|
}
|
|
|
|
func createProject(t *testing.T, ctx context.Context, store *db.Store, name, slug string) *db.Project {
|
|
t.Helper()
|
|
|
|
project, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: name, Slug: slug})
|
|
if err != nil {
|
|
t.Fatalf("create project %s: %v", name, err)
|
|
}
|
|
|
|
return project
|
|
}
|
|
|
|
func createTag(t *testing.T, ctx context.Context, store *db.Store, name, slug string) *db.Tag {
|
|
t.Helper()
|
|
|
|
tag, err := store.Tags.Create(ctx, db.CreateTagParams{Name: name, Slug: slug})
|
|
if err != nil {
|
|
t.Fatalf("create tag %s: %v", name, err)
|
|
}
|
|
|
|
return tag
|
|
}
|
|
|
|
func attachTag(t *testing.T, ctx context.Context, store *db.Store, projectID, tagID int64) {
|
|
t.Helper()
|
|
|
|
if err := store.Projects.AttachTag(ctx, projectID, tagID); err != nil {
|
|
t.Fatalf("attach tag %d to project %d: %v", tagID, projectID, err)
|
|
}
|
|
}
|