294 lines
11 KiB
Go
294 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"cmbuyer/admin/internal/deviceauth"
|
|
"cmbuyer/admin/internal/migrations"
|
|
"cmbuyer/admin/internal/storage/sqlite"
|
|
)
|
|
|
|
func TestIssueListAndIdempotentRevokeNeverRediscloseSecret(t *testing.T) {
|
|
databaseSource := migratedDatabase(t)
|
|
var issued bytes.Buffer
|
|
if err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "采购工具一号"}, &issued, io.Discard); err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
fields := outputFields(t, issued.String())
|
|
deviceID, token := fields["device_id"], fields["token"]
|
|
if len(token) != 64 || strings.Count(issued.String(), token) != 1 {
|
|
t.Fatalf("issue token occurrence/length = %d/%d", strings.Count(issued.String(), token), len(token))
|
|
}
|
|
|
|
var listed bytes.Buffer
|
|
if err := run(context.Background(), []string{"-database", databaseSource, "list"}, &listed, io.Discard); err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
assertNoSecretMetadata(t, listed.String(), token)
|
|
if !strings.Contains(listed.String(), deviceID) || !strings.Contains(listed.String(), "采购工具一号") {
|
|
t.Fatalf("list omitted safe metadata: %s", listed.String())
|
|
}
|
|
|
|
var revoked bytes.Buffer
|
|
if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &revoked, io.Discard); err != nil {
|
|
t.Fatalf("revoke: %v", err)
|
|
}
|
|
assertNoSecretMetadata(t, revoked.String(), token)
|
|
if !strings.Contains(revoked.String(), `"revoked_now":true`) {
|
|
t.Fatalf("first revoke output = %s", revoked.String())
|
|
}
|
|
var repeated bytes.Buffer
|
|
if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &repeated, io.Discard); err != nil {
|
|
t.Fatalf("repeat revoke: %v", err)
|
|
}
|
|
assertNoSecretMetadata(t, repeated.String(), token)
|
|
if !strings.Contains(repeated.String(), `"revoked_now":false`) {
|
|
t.Fatalf("repeat revoke output = %s", repeated.String())
|
|
}
|
|
}
|
|
|
|
func TestIssueOutputFailureLeavesCommittedCredentialWithoutSecretInError(t *testing.T) {
|
|
databaseSource := migratedDatabase(t)
|
|
writer := &recordingFailureWriter{}
|
|
err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "output failure"}, writer, io.Discard)
|
|
if err == nil || err.Error() != "write issued device credential" {
|
|
t.Fatalf("issue output failure error = %v", err)
|
|
}
|
|
fields := outputFields(t, writer.contents.String())
|
|
if strings.Contains(err.Error(), fields["token"]) {
|
|
t.Fatal("output error disclosed token")
|
|
}
|
|
database, err := sql.Open("sqlite3", databaseSource)
|
|
if err != nil {
|
|
t.Fatalf("open database: %v", err)
|
|
}
|
|
defer database.Close()
|
|
var count int
|
|
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 {
|
|
t.Fatalf("committed credential count = %d, err=%v", count, err)
|
|
}
|
|
}
|
|
|
|
func TestCLIRequiresPreMigratedExplicitDatabase(t *testing.T) {
|
|
if err := run(context.Background(), []string{"list"}, io.Discard, io.Discard); err == nil {
|
|
t.Fatal("command without -database succeeded")
|
|
}
|
|
missing := filepath.Join(t.TempDir(), "missing.db")
|
|
if err := run(context.Background(), []string{"-database", missing, "list"}, io.Discard, io.Discard); err == nil {
|
|
t.Fatal("list opened a missing database")
|
|
}
|
|
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("missing database was created: %v", err)
|
|
}
|
|
|
|
unmigrated := filepath.Join(t.TempDir(), "unmigrated.db")
|
|
unmigratedDatabase, err := sqlite.Open(unmigrated)
|
|
if err != nil {
|
|
t.Fatalf("create unmigrated database: %v", err)
|
|
}
|
|
if _, err := unmigratedDatabase.Exec(`CREATE TABLE unrelated (id INTEGER)`); err != nil {
|
|
_ = unmigratedDatabase.Close()
|
|
t.Fatalf("initialize unmigrated database: %v", err)
|
|
}
|
|
if err := unmigratedDatabase.Close(); err != nil {
|
|
t.Fatalf("close unmigrated database: %v", err)
|
|
}
|
|
if err := run(context.Background(), []string{"-database", unmigrated, "list"}, io.Discard, io.Discard); err == nil {
|
|
t.Fatal("list accepted an unmigrated database")
|
|
}
|
|
database, err := sql.Open("sqlite3", unmigrated)
|
|
if err != nil {
|
|
t.Fatalf("open unmigrated database: %v", err)
|
|
}
|
|
defer database.Close()
|
|
var count int
|
|
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='device_credentials'`).Scan(&count); err != nil || count != 0 {
|
|
t.Fatalf("device_credentials table count = %d, err=%v", count, err)
|
|
}
|
|
|
|
undeclared := filepath.Join(t.TempDir(), "undeclared.db")
|
|
if err := run(context.Background(), []string{"-database", undeclared, "rotate"}, io.Discard, io.Discard); err == nil {
|
|
t.Fatal("undeclared command succeeded")
|
|
}
|
|
if _, err := os.Stat(undeclared); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("undeclared command opened database: %v", err)
|
|
}
|
|
|
|
invalidIssue := filepath.Join(t.TempDir(), "invalid-issue.db")
|
|
if err := run(context.Background(), []string{"-database", invalidIssue, "issue", "-name", " padded"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) {
|
|
t.Fatalf("invalid issue error = %v", err)
|
|
}
|
|
if _, err := os.Stat(invalidIssue); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("invalid issue opened database: %v", err)
|
|
}
|
|
|
|
invalidRevoke := filepath.Join(t.TempDir(), "invalid-revoke.db")
|
|
if err := run(context.Background(), []string{"-database", invalidRevoke, "revoke", "-device-id", "not-a-uuid"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) {
|
|
t.Fatalf("invalid revoke error = %v", err)
|
|
}
|
|
if _, err := os.Stat(invalidRevoke); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("invalid revoke opened database: %v", err)
|
|
}
|
|
|
|
migrated := migratedDatabase(t)
|
|
unknownID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
|
|
var unknownOutput bytes.Buffer
|
|
err = run(context.Background(), []string{"-database", migrated, "revoke", "-device-id", unknownID}, &unknownOutput, io.Discard)
|
|
if !errors.Is(err, deviceauth.ErrCredentialNotFound) || unknownOutput.Len() != 0 || strings.Contains(err.Error(), unknownID) {
|
|
t.Fatalf("unknown revoke = output %q, error %v", unknownOutput.String(), err)
|
|
}
|
|
}
|
|
|
|
func TestExistingSQLiteDataSourcePreservesSafeOptionsAndRejectsCreationModes(t *testing.T) {
|
|
databaseSource := migratedDatabase(t)
|
|
fileURI := (&url.URL{
|
|
Scheme: "file",
|
|
Path: sqliteURIPath(databaseSource),
|
|
RawQuery: "_busy_timeout=5000&cache=shared",
|
|
}).String()
|
|
normalized, err := existingSQLiteDataSource(fileURI)
|
|
if err != nil {
|
|
t.Fatalf("normalize file URI: %v", err)
|
|
}
|
|
parsed, err := url.Parse(normalized)
|
|
if err != nil {
|
|
t.Fatalf("parse normalized URI: %v", err)
|
|
}
|
|
query := parsed.Query()
|
|
if query.Get("mode") != "rw" || query.Get("_busy_timeout") != "5000" || query.Get("cache") != "shared" {
|
|
t.Fatalf("normalized query = %v", query)
|
|
}
|
|
if err := run(context.Background(), []string{"-database", fileURI, "list"}, io.Discard, io.Discard); err != nil {
|
|
t.Fatalf("list existing file URI: %v", err)
|
|
}
|
|
|
|
plainNormalized, err := existingSQLiteDataSource(databaseSource + "?_foreign_keys=on")
|
|
if err != nil {
|
|
t.Fatalf("normalize ordinary path: %v", err)
|
|
}
|
|
plainURI, err := url.Parse(plainNormalized)
|
|
if err != nil || plainURI.Scheme != "file" || plainURI.Query().Get("mode") != "rw" || plainURI.Query().Get("_foreign_keys") != "on" {
|
|
t.Fatalf("ordinary path normalization = %q, err=%v", plainNormalized, err)
|
|
}
|
|
|
|
missing := filepath.Join(t.TempDir(), "missing-uri.db")
|
|
missingURI := (&url.URL{Scheme: "file", Path: sqliteURIPath(missing)}).String()
|
|
if err := run(context.Background(), []string{"-database", missingURI, "list"}, io.Discard, io.Discard); err == nil {
|
|
t.Fatal("missing file URI succeeded")
|
|
}
|
|
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("missing file URI created a file: %v", err)
|
|
}
|
|
for _, scheme := range []string{"FILE", "File"} {
|
|
mixedMissing := filepath.Join(t.TempDir(), strings.ToLower(scheme)+"-missing.db")
|
|
canonical := (&url.URL{Scheme: "file", Path: sqliteURIPath(mixedMissing)}).String()
|
|
mixedURI := scheme + canonical[len("file"):]
|
|
normalized, err := existingSQLiteDataSource(mixedURI)
|
|
if err != nil || !strings.HasPrefix(normalized, "file:") {
|
|
t.Fatalf("normalize %s URI = %q, err=%v", scheme, normalized, err)
|
|
}
|
|
if err := run(context.Background(), []string{"-database", mixedURI, "list"}, io.Discard, io.Discard); err == nil {
|
|
t.Fatalf("missing %s URI succeeded", scheme)
|
|
}
|
|
if _, err := os.Stat(mixedMissing); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("missing %s URI created a file: %v", scheme, err)
|
|
}
|
|
}
|
|
|
|
for name, source := range map[string]string{
|
|
"plain memory": ":memory:",
|
|
"URI memory": "file::memory:?cache=shared",
|
|
"memory mode": fileURI + "&mode=memory",
|
|
"read only mode": fileURI + "&mode=ro",
|
|
"create mode": fileURI + "&mode=rwc",
|
|
"duplicate mode": fileURI + "&mode=rw&mode=rw",
|
|
"immutable": fileURI + "&immutable=1",
|
|
"query only": fileURI + "&_query_only=1",
|
|
"remote authority": "file://server/share/database.db?mode=rw",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := existingSQLiteDataSource(source); err == nil {
|
|
t.Fatalf("unsafe source accepted: %q", source)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func sqliteURIPath(path string) string {
|
|
normalized := filepath.ToSlash(path)
|
|
if filepath.VolumeName(path) != "" && !strings.HasPrefix(normalized, "/") {
|
|
return "/" + normalized
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
type recordingFailureWriter struct {
|
|
contents bytes.Buffer
|
|
}
|
|
|
|
func (writer *recordingFailureWriter) Write(value []byte) (int, error) {
|
|
_, _ = writer.contents.Write(value)
|
|
return 0, errors.New("injected stdout failure")
|
|
}
|
|
|
|
func assertNoSecretMetadata(t *testing.T, output, token string) {
|
|
t.Helper()
|
|
if strings.Contains(output, token) || strings.Contains(output, "token") || strings.Contains(output, "hash") || strings.Contains(output, "sha256") {
|
|
t.Fatalf("metadata output disclosed secret material: %s", output)
|
|
}
|
|
}
|
|
|
|
func outputFields(t *testing.T, output string) map[string]string {
|
|
t.Helper()
|
|
fields := make(map[string]string)
|
|
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
|
name, value, found := strings.Cut(line, "=")
|
|
if !found || name == "" || value == "" {
|
|
t.Fatalf("invalid issue output line %q", line)
|
|
}
|
|
fields[name] = value
|
|
}
|
|
for _, required := range []string{"device_id", "display_name", "token", "created_at"} {
|
|
if fields[required] == "" {
|
|
t.Fatalf("issue output missing %s: %q", required, output)
|
|
}
|
|
}
|
|
return fields
|
|
}
|
|
|
|
func migratedDatabase(t *testing.T) string {
|
|
t.Helper()
|
|
databaseSource := filepath.Join(t.TempDir(), "credentials.db")
|
|
database, err := sqlite.Open(databaseSource)
|
|
if err != nil {
|
|
t.Fatalf("open database: %v", err)
|
|
}
|
|
if err := migrations.Up(context.Background(), database, commandMigrationDirectory(t)); err != nil {
|
|
_ = database.Close()
|
|
t.Fatalf("migrate database: %v", err)
|
|
}
|
|
if err := database.Close(); err != nil {
|
|
t.Fatalf("close migrated database: %v", err)
|
|
}
|
|
return databaseSource
|
|
}
|
|
|
|
func commandMigrationDirectory(t *testing.T) string {
|
|
t.Helper()
|
|
_, file, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("locate migrations")
|
|
}
|
|
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
|
}
|