330 lines
9.1 KiB
Go
330 lines
9.1 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func mkKey(t *testing.T, db *DB, id string, scope Scope, projectID *int64) *APIKey {
|
|
t.Helper()
|
|
hash := bytes.Repeat([]byte{byte(len(id))}, 32)
|
|
k := &APIKey{ID: id, SecretHash: hash, Scope: scope, ProjectID: projectID, Name: "test " + id}
|
|
if err := db.CreateKey(context.Background(), k); err != nil {
|
|
t.Fatalf("CreateKey(%s): %v", id, err)
|
|
}
|
|
return k
|
|
}
|
|
|
|
func TestCreateAndReadKey(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := DefaultProject("demo")
|
|
if err := db.CreateProject(ctx, p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
admin := mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil)
|
|
proj := mkKey(t, db, "projkeyid0000000", ScopeProject, &p.ID)
|
|
|
|
got, err := db.KeyByID(ctx, admin.ID)
|
|
if err != nil {
|
|
t.Fatalf("KeyByID: %v", err)
|
|
}
|
|
if got.Scope != ScopeAdmin {
|
|
t.Errorf("scope = %q, want admin", got.Scope)
|
|
}
|
|
if got.ProjectID != nil {
|
|
t.Errorf("admin key has project_id %v", *got.ProjectID)
|
|
}
|
|
if !bytes.Equal(got.SecretHash, admin.SecretHash) {
|
|
t.Error("secret hash did not round trip")
|
|
}
|
|
if got.CreatedAt.IsZero() {
|
|
t.Error("created_at not set")
|
|
}
|
|
if got.RevokedAt != nil || got.ExpiresAt != nil || got.LastUsedAt != nil {
|
|
t.Errorf("optional timestamps should be nil: %+v", got)
|
|
}
|
|
|
|
got, err = db.KeyByID(ctx, proj.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.ProjectID == nil || *got.ProjectID != p.ID {
|
|
t.Errorf("project key lost its project: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestKeyNotFound(t *testing.T) {
|
|
db := testDB(t)
|
|
if _, err := db.KeyByID(context.Background(), "missing000000000"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("got %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateKeyDuplicateID(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil)
|
|
err := db.CreateKey(ctx, &APIKey{ID: "adminkeyid000000", SecretHash: make([]byte, 32), Scope: ScopeAdmin})
|
|
if !errors.Is(err, ErrExists) {
|
|
t.Fatalf("got %v, want ErrExists", err)
|
|
}
|
|
}
|
|
|
|
func TestRevokeKey(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
k := mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil)
|
|
|
|
if err := db.RevokeKey(ctx, k.ID); err != nil {
|
|
t.Fatalf("RevokeKey: %v", err)
|
|
}
|
|
got, err := db.KeyByID(ctx, k.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.RevokedAt == nil {
|
|
t.Fatal("revoked_at not set")
|
|
}
|
|
first := *got.RevokedAt
|
|
if got.Usable(time.Now()) {
|
|
t.Error("a revoked key must not be usable")
|
|
}
|
|
|
|
// Revoking again must be a no-op, not a moved timestamp: the first time is
|
|
// when the key actually stopped working.
|
|
if err := db.RevokeKey(ctx, k.ID); err != nil {
|
|
t.Fatalf("second RevokeKey: %v", err)
|
|
}
|
|
got, err = db.KeyByID(ctx, k.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !got.RevokedAt.Equal(first) {
|
|
t.Errorf("revoked_at moved from %v to %v", first, *got.RevokedAt)
|
|
}
|
|
|
|
if err := db.RevokeKey(ctx, "missing000000000"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("revoking an unknown key: got %v, want ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestKeyUsable(t *testing.T) {
|
|
now := time.Unix(1_000_000, 0).UTC()
|
|
past := now.Add(-time.Hour)
|
|
future := now.Add(time.Hour)
|
|
|
|
cases := []struct {
|
|
name string
|
|
key APIKey
|
|
wantUse bool
|
|
}{
|
|
{"fresh", APIKey{}, true},
|
|
{"revoked", APIKey{RevokedAt: &past}, false},
|
|
{"expired", APIKey{ExpiresAt: &past}, false},
|
|
{"expires later", APIKey{ExpiresAt: &future}, true},
|
|
{"expires exactly now", APIKey{ExpiresAt: &now}, false},
|
|
{"revoked and unexpired", APIKey{RevokedAt: &past, ExpiresAt: &future}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := tc.key.Usable(now); got != tc.wantUse {
|
|
t.Errorf("Usable = %v, want %v", got, tc.wantUse)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListKeys(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
a := DefaultProject("alpha")
|
|
b := DefaultProject("beta")
|
|
if err := db.CreateProject(ctx, a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.CreateProject(ctx, b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil)
|
|
mkKey(t, db, "alphakey00000001", ScopeProject, &a.ID)
|
|
mkKey(t, db, "alphakey00000002", ScopeProject, &a.ID)
|
|
mkKey(t, db, "betakey000000001", ScopeProject, &b.ID)
|
|
|
|
all, err := db.ListKeys(ctx, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(all) != 4 {
|
|
t.Errorf("ListKeys(nil) returned %d keys, want 4", len(all))
|
|
}
|
|
|
|
forA, err := db.ListKeys(ctx, &a.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(forA) != 2 {
|
|
t.Fatalf("ListKeys(alpha) returned %d keys, want 2", len(forA))
|
|
}
|
|
for _, k := range forA {
|
|
if k.ProjectID == nil || *k.ProjectID != a.ID {
|
|
t.Errorf("key %s leaked into alpha's list", k.ID)
|
|
}
|
|
}
|
|
|
|
// Revoked keys stay listed so an operator can see what was revoked and when.
|
|
if err := db.RevokeKey(ctx, "alphakey00000001"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
forA, err = db.ListKeys(ctx, &a.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(forA) != 2 {
|
|
t.Errorf("after revoke, ListKeys(alpha) returned %d keys, want 2", len(forA))
|
|
}
|
|
}
|
|
|
|
// A zero result here is what makes the server mint a bootstrap token, so the
|
|
// "usable" definition has to match what the verifier will accept.
|
|
func TestCountUsableAdminKeys(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := DefaultProject("demo")
|
|
if err := db.CreateProject(ctx, p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if n, err := db.CountUsableAdminKeys(ctx); err != nil || n != 0 {
|
|
t.Fatalf("empty database: n=%d err=%v, want 0", n, err)
|
|
}
|
|
|
|
// A project key is not an admin key.
|
|
mkKey(t, db, "projkeyid0000000", ScopeProject, &p.ID)
|
|
if n, _ := db.CountUsableAdminKeys(ctx); n != 0 {
|
|
t.Errorf("project key counted as admin: n=%d", n)
|
|
}
|
|
|
|
mkKey(t, db, "adminkeyid000000", ScopeAdmin, nil)
|
|
if n, _ := db.CountUsableAdminKeys(ctx); n != 1 {
|
|
t.Errorf("n=%d, want 1", n)
|
|
}
|
|
|
|
// An expired admin key must not keep the server from bootstrapping.
|
|
expired := time.Now().Add(-time.Hour)
|
|
if err := db.CreateKey(ctx, &APIKey{
|
|
ID: "expiredadmin0000", SecretHash: make([]byte, 32), Scope: ScopeAdmin, ExpiresAt: &expired,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n, _ := db.CountUsableAdminKeys(ctx); n != 1 {
|
|
t.Errorf("expired admin key counted: n=%d", n)
|
|
}
|
|
|
|
if err := db.RevokeKey(ctx, "adminkeyid000000"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n, _ := db.CountUsableAdminKeys(ctx); n != 0 {
|
|
t.Errorf("revoked admin key counted: n=%d", n)
|
|
}
|
|
}
|
|
|
|
func TestTouchKeys(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
mkKey(t, db, "key0000000000001", ScopeAdmin, nil)
|
|
mkKey(t, db, "key0000000000002", ScopeAdmin, nil)
|
|
|
|
if err := db.TouchKeys(ctx, nil); err != nil {
|
|
t.Errorf("empty batch should be a no-op: %v", err)
|
|
}
|
|
|
|
t1 := time.Unix(1_700_000_000, 0)
|
|
if err := db.TouchKeys(ctx, map[string]time.Time{
|
|
"key0000000000001": t1,
|
|
"key0000000000002": t1,
|
|
// A key that vanished between the request and the flush must not fail
|
|
// the whole batch, or one deleted key would stall the flusher forever.
|
|
"deletedkey000000": t1,
|
|
}); err != nil {
|
|
t.Fatalf("TouchKeys: %v", err)
|
|
}
|
|
lastUsed := func(id string) *time.Time {
|
|
t.Helper()
|
|
k, err := db.KeyByID(ctx, id)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return k.LastUsedAt
|
|
}
|
|
if got := lastUsed("key0000000000001"); got == nil || !got.Equal(t1.UTC()) {
|
|
t.Errorf("last_used_at = %v, want %v", got, t1.UTC())
|
|
}
|
|
|
|
// Batches can arrive out of order once the flusher runs concurrently with a
|
|
// retry; an older timestamp must not walk the column backwards.
|
|
older := t1.Add(-time.Hour)
|
|
if err := db.TouchKeys(ctx, map[string]time.Time{"key0000000000001": older}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := lastUsed("key0000000000001"); !got.Equal(t1.UTC()) {
|
|
t.Errorf("last_used_at moved backwards to %v", got)
|
|
}
|
|
|
|
newer := t1.Add(time.Hour)
|
|
if err := db.TouchKeys(ctx, map[string]time.Time{"key0000000000001": newer}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := lastUsed("key0000000000001"); !got.Equal(newer.UTC()) {
|
|
t.Errorf("last_used_at = %v, want %v", got, newer.UTC())
|
|
}
|
|
}
|
|
|
|
// The write pool holds a single connection, so a batch flush must not need more
|
|
// than one; this would deadlock if TouchKeys opened a nested transaction.
|
|
func TestTouchKeysLargeBatch(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
seen := map[string]time.Time{}
|
|
now := time.Unix(1_700_000_000, 0)
|
|
for i := 0; i < 200; i++ {
|
|
id := fmt.Sprintf("key%013d", i)
|
|
mkKey(t, db, id, ScopeAdmin, nil)
|
|
seen[id] = now
|
|
}
|
|
if err := db.TouchKeys(ctx, seen); err != nil {
|
|
t.Fatalf("TouchKeys: %v", err)
|
|
}
|
|
var n int
|
|
if err := db.Reader().QueryRow(
|
|
`SELECT count(*) FROM api_keys WHERE last_used_at = ?`, now.Unix()).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 200 {
|
|
t.Errorf("%d keys touched, want 200", n)
|
|
}
|
|
}
|
|
|
|
// Keys must die with their project, or a project name could be recreated and
|
|
// inherit the old owner's credentials.
|
|
func TestKeysCascadeWithProject(t *testing.T) {
|
|
ctx := context.Background()
|
|
db := testDB(t)
|
|
p := DefaultProject("demo")
|
|
if err := db.CreateProject(ctx, p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mkKey(t, db, "projkeyid0000000", ScopeProject, &p.ID)
|
|
if err := db.DeleteProject(ctx, p.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.KeyByID(ctx, "projkeyid0000000"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("key survived its project: %v", err)
|
|
}
|
|
}
|