Files
ResolveSpec/pkg/security/lookup/direct/passkey_totp_test.go
T
Hein c9fa8c60f2 refactor(security): move all database access into pkg/security/lookup
pkg/security no longer contains SQL. Every provider calls a store interface
from lookup, implemented by a procedure backend (Postgres stored procedures,
the default there) and a direct backend (dialect-driven SQL for postgres,
sqlite, mysql and mssql with configurable table and column names).

- add sectypes, lookup, lookup/{dialect,procedure,direct,backends,ddl,conformance}
- split totp and providers sub packages out of the core package
- replace SQLNames/TableNames/QueryMode with lookup.Config (see breaking_changes.md)
- direct backend now covers column/row security and API-key login
- move txsettings SQL to lookup.ApplyTxSettings; remove password.go
- move schema scripts under lookup/, add reference DDL per dialect
- add a shared conformance suite; run it on sqlite, and on Postgres in a
  podman/docker container (RESOLVESPEC_TEST_CONTAINERS=1)
- fix procedure schema bugs found on real Postgres: duplicate p_data
  parameter, JSON null arrays, expires_at timezone casts, passkey list
  GROUP BY, missing resolvespec_passkey_login; accept zone-less timestamps
2026-10-01 13:19:44 +02:00

163 lines
4.8 KiB
Go

package direct
import (
"context"
"encoding/base64"
"testing"
"github.com/bitechdev/ResolveSpec/pkg/security/lookup"
)
func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) }
func TestPasskeyLifecycle(t *testing.T) {
ctx := context.Background()
a, _ := newAuth(t, AuthOptions{})
reg := registerUser(t, a, "pat")
uid := reg.User.UserID
p := NewPasskey(a.Base)
rec := lookup.PasskeyCredentialRecord{UserID: uid, CredentialID: b64("cred1"), PublicKey: b64("pk"), AttestationType: "none",
Transports: []string{"usb", "nfc"}, Name: "Key 1"}
id, err := p.Store(ctx, rec)
if err != nil || id == 0 {
t.Fatalf("%d %v", id, err)
}
if _, err := p.Store(ctx, rec); err == nil || err.Error() != "credential already exists" {
t.Fatalf("dup: %v", err)
}
rec.CredentialID, rec.UserID = b64("cred2"), 999
if _, err := p.Store(ctx, rec); err == nil || err.Error() != "user not found" {
t.Fatalf("no user: %v", err)
}
rec.UserID, rec.Name = uid, "Key 2"
if _, err := p.Store(ctx, rec); err != nil {
t.Fatal(err)
}
owner, count, err := p.Get(ctx, b64("cred1"))
if err != nil || owner != uid || count != 0 {
t.Fatalf("%d %d %v", owner, count, err)
}
if _, _, err := p.Get(ctx, b64("zzz")); err == nil || err.Error() != "credential not found" {
t.Fatalf("got %v", err)
}
if clone, err := p.UpdateCounter(ctx, b64("cred1"), 5); err != nil || clone {
t.Fatalf("%v %v", clone, err)
}
if clone, err := p.UpdateCounter(ctx, b64("cred1"), 5); err != nil || !clone {
t.Fatalf("replayed counter must flag clone: %v %v", clone, err)
}
if _, count, _ = p.Get(ctx, b64("cred1")); count != 5 {
t.Fatalf("counter changed on clone: %d", count)
}
if _, err := p.UpdateCounter(ctx, b64("missing"), 1); err == nil {
t.Fatal("expected not found")
}
list, err := p.List(ctx, uid)
if err != nil || len(list) != 2 {
t.Fatalf("%d %v", len(list), err)
}
for _, c := range list {
if string(c.CredentialID) == "cred1" {
if !c.CloneWarning || c.SignCount != 5 || len(c.Transports) != 2 || c.Name != "Key 1" {
t.Fatalf("%+v", c)
}
}
}
if err := p.Rename(ctx, uid, b64("cred1"), "Renamed"); err != nil {
t.Fatal(err)
}
if err := p.Rename(ctx, uid+1, b64("cred1"), "x"); err == nil {
t.Fatal("renamed another user's credential")
}
gotID, refs, err := p.ByUsername(ctx, "pat")
if err != nil || gotID != uid || len(refs) != 2 {
t.Fatalf("%d %+v %v", gotID, refs, err)
}
if _, _, err := p.ByUsername(ctx, "ghost"); err == nil || err.Error() != "user not found" {
t.Fatalf("got %v", err)
}
resp, err := p.Login(ctx, uid, map[string]any{"ip_address": "1.1.1.1"})
if err != nil || resp.User.UserName != "pat" || resp.ExpiresIn != 86400 {
t.Fatalf("%+v %v", resp, err)
}
if _, err := a.Session(ctx, resp.Token, ""); err != nil {
t.Fatal(err)
}
if err := p.Delete(ctx, uid+1, b64("cred1")); err == nil {
t.Fatal("deleted another user's credential")
}
if err := p.Delete(ctx, uid, b64("cred1")); err != nil {
t.Fatal(err)
}
if err := p.Delete(ctx, uid, b64("cred1")); err == nil || err.Error() != "credential not found" {
t.Fatalf("got %v", err)
}
}
func TestTOTPLifecycle(t *testing.T) {
ctx := context.Background()
a, _ := newAuth(t, AuthOptions{})
uid := registerUser(t, a, "tom").User.UserID
s := NewTOTP(a.Base)
if on, err := s.Status(ctx, uid); err != nil || on {
t.Fatalf("%v %v", on, err)
}
if _, err := s.Secret(ctx, uid); err == nil || err.Error() != "TOTP not enabled for user" {
t.Fatalf("got %v", err)
}
if err := s.RegenerateBackupCodes(ctx, uid, []string{"h"}); err == nil {
t.Fatal("regenerate without 2FA")
}
if err := s.Enable(ctx, 999, "S", nil); err == nil || err.Error() != "user not found" {
t.Fatalf("got %v", err)
}
if err := s.Enable(ctx, uid, "SECRET", []string{"h1", "h2"}); err != nil {
t.Fatal(err)
}
if on, _ := s.Status(ctx, uid); !on {
t.Fatal("not enabled")
}
if sec, err := s.Secret(ctx, uid); err != nil || sec != "SECRET" {
t.Fatalf("%q %v", sec, err)
}
if ok, err := s.ValidateBackupCode(ctx, uid, "h1"); err != nil || !ok {
t.Fatalf("%v %v", ok, err)
}
if _, err := s.ValidateBackupCode(ctx, uid, "h1"); err == nil || err.Error() != "backup code already used" {
t.Fatalf("reuse: %v", err)
}
if ok, err := s.ValidateBackupCode(ctx, uid, "nope"); err != nil || ok {
t.Fatalf("%v %v", ok, err)
}
if err := s.RegenerateBackupCodes(ctx, uid, []string{"n1"}); err != nil {
t.Fatal(err)
}
if ok, _ := s.ValidateBackupCode(ctx, uid, "h2"); ok {
t.Fatal("old code survived regenerate")
}
if ok, _ := s.ValidateBackupCode(ctx, uid, "n1"); !ok {
t.Fatal("new code rejected")
}
if err := s.Disable(ctx, uid); err != nil {
t.Fatal(err)
}
if on, _ := s.Status(ctx, uid); on {
t.Fatal("still enabled")
}
if ok, _ := s.ValidateBackupCode(ctx, uid, "n1"); ok {
t.Fatal("codes survived disable")
}
}