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) } }